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:
9
.gitmodules
vendored
9
.gitmodules
vendored
@@ -29,3 +29,12 @@
|
||||
[submodule "external/hibou-shipbox"]
|
||||
path = external/hibou-shipbox
|
||||
url = https://github.com/hibou-io/shipbox.git
|
||||
[submodule "external/hibou-oca/purchase-workflow"]
|
||||
path = external/hibou-oca/purchase-workflow
|
||||
url = https://github.com/hibou-io/oca-purchase-workflow.git
|
||||
[submodule "external/hibou-oca/stock-logistics-workflow"]
|
||||
path = external/hibou-oca/stock-logistics-workflow
|
||||
url = https://github.com/hibou-io/oca-stock-logistics-workflow.git
|
||||
[submodule "external/hibou-oca/stock-logistics-warehouse"]
|
||||
path = external/hibou-oca/stock-logistics-warehouse
|
||||
url = https://github.com/hibou-io/stock-logistics-warehouse.git
|
||||
|
||||
28
account_invoice_margin/README.rst
Normal file
28
account_invoice_margin/README.rst
Normal file
@@ -0,0 +1,28 @@
|
||||
******************************
|
||||
Hibou - Account Invoice Margin
|
||||
******************************
|
||||
|
||||
Include a margin calculation on invoices.
|
||||
|
||||
For more information and add-ons, visit `Hibou.io <https://hibou.io/docs/hibou-odoo-suite-1/invoice-margin-156>`_.
|
||||
|
||||
|
||||
=============
|
||||
Main Features
|
||||
=============
|
||||
|
||||
* Adds computed field `margin` to invoices to give the profitability by calculating the difference between the Unit Price and the Cost.
|
||||
|
||||
.. image:: https://user-images.githubusercontent.com/15882954/45578631-880c0000-b837-11e8-9c4d-d2f15c3c0592.png
|
||||
:alt: 'Customer Invoice'
|
||||
:width: 988
|
||||
:align: left
|
||||
|
||||
|
||||
=======
|
||||
License
|
||||
=======
|
||||
|
||||
Please see `LICENSE <https://github.com/hibou-io/hibou-odoo-suite/blob/11.0/LICENSE>`_.
|
||||
|
||||
Copyright Hibou Corp. 2018
|
||||
1
account_us_wa_salestax/__init__.py
Normal file
1
account_us_wa_salestax/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
13
account_us_wa_salestax/__manifest__.py
Normal file
13
account_us_wa_salestax/__manifest__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
{'name': 'US WA State SalesTax API',
|
||||
'version': '10.0.1.0.0',
|
||||
'category': 'Tools',
|
||||
'depends': ['account',
|
||||
],
|
||||
'author': 'Hibou Corp.',
|
||||
'license': 'AGPL-3',
|
||||
'website': 'https://hibou.io/',
|
||||
'data': ['views/account_fiscal_position_view.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
}
|
||||
2
account_us_wa_salestax/models/__init__.py
Normal file
2
account_us_wa_salestax/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import account_fiscal_position
|
||||
from . import wa_tax_request
|
||||
78
account_us_wa_salestax/models/account_fiscal_position.py
Normal file
78
account_us_wa_salestax/models/account_fiscal_position.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
from .wa_tax_request import WATaxRequest
|
||||
|
||||
|
||||
class AccountFiscalPosition(models.Model):
|
||||
_inherit = 'account.fiscal.position'
|
||||
|
||||
is_us_wa = fields.Boolean(string='Use WA State API')
|
||||
wa_base_tax_id = fields.Many2one('account.tax', string='WA Base/Error Tax')
|
||||
|
||||
@api.multi
|
||||
def map_tax(self, taxes, product=None, partner=None):
|
||||
|
||||
if not taxes or not self.is_us_wa or partner is None:
|
||||
return super(AccountFiscalPosition, self).map_tax(taxes)
|
||||
|
||||
AccountTax = self.env['account.tax'].sudo()
|
||||
result = AccountTax.browse()
|
||||
|
||||
for tax in taxes:
|
||||
# step 1: If we were to save the location code on the partner we might not have to do this
|
||||
request = WATaxRequest()
|
||||
res = request.get_rate(partner)
|
||||
wa_tax = None
|
||||
if not request.is_success(res):
|
||||
# Cache.
|
||||
wa_tax = AccountTax.search([
|
||||
('wa_location_zips', 'like', '%' + partner.zip + '%'),
|
||||
('amount_type', '=', 'percent'),
|
||||
('type_tax_use', '=', 'sale')], limit=1)
|
||||
if not wa_tax:
|
||||
result |= self.wa_base_tax_id
|
||||
continue
|
||||
|
||||
# step 2: Find or create tax
|
||||
if not wa_tax:
|
||||
wa_tax = AccountTax.search([
|
||||
('wa_location_code', '=', res['location_code']),
|
||||
('amount', '=', res['rate']),
|
||||
('amount_type', '=', 'percent'),
|
||||
('type_tax_use', '=', 'sale')], limit=1)
|
||||
if not wa_tax:
|
||||
wa_tax = AccountTax.create({
|
||||
'name': '%s - WA Tax %s %%' % (res['location_code'], res['rate']),
|
||||
'wa_location_code': res['location_code'],
|
||||
'amount': res['rate'],
|
||||
'amount_type': 'percent',
|
||||
'type_tax_use': 'sale',
|
||||
'account_id': self.wa_base_tax_id.account_id.id,
|
||||
'refund_account_id': self.wa_base_tax_id.refund_account_id.id
|
||||
})
|
||||
|
||||
if not wa_tax.wa_location_zips:
|
||||
wa_tax.wa_location_zips = partner.zip
|
||||
elif not wa_tax.wa_location_zips.find(partner.zip) >= 0:
|
||||
zips = wa_tax.wa_location_zips.split(',')
|
||||
zips.append(partner.zip)
|
||||
wa_tax.wa_location_zips = zips.append(',')
|
||||
|
||||
# step 3: Find or create mapping
|
||||
tax_line = self.tax_ids.filtered(lambda x: x.tax_src_id.id == tax.id and x.tax_dest_id.id == wa_tax.id)
|
||||
if not tax_line:
|
||||
tax_line = self.env['account.fiscal.position.tax'].sudo().create({
|
||||
'position_id': self.id,
|
||||
'tax_src_id': tax.id,
|
||||
'tax_dest_id': wa_tax.id,
|
||||
})
|
||||
|
||||
result |= tax_line.tax_dest_id
|
||||
return result
|
||||
|
||||
class AccountTax(models.Model):
|
||||
_inherit = 'account.tax'
|
||||
|
||||
wa_location_code = fields.Integer('WA Location Code')
|
||||
wa_location_zips = fields.Char('WA Location ZIPs', default='')
|
||||
74
account_us_wa_salestax/models/wa_tax_request.py
Normal file
74
account_us_wa_salestax/models/wa_tax_request.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from urllib.request import urlopen, quote
|
||||
from urllib.error import HTTPError
|
||||
from ssl import _create_unverified_context
|
||||
from logging import getLogger
|
||||
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
_logger = getLogger(__name__)
|
||||
|
||||
|
||||
class WATaxRequest(object):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def get_rate(self, partner):
|
||||
# https://webgis.dor.wa.gov/webapi/addressrates.aspx/?output=text\&addr=test\&city=Marysville\&zip=98270
|
||||
if not all((partner.street, partner.city, partner.zip)):
|
||||
raise ValidationError('WATaxRequest impossible without Street, City and ZIP.')
|
||||
url = 'https://webgis.dor.wa.gov/webapi/addressrates.aspx?output=text&addr=' + quote(partner.street) + \
|
||||
'&city=' + quote(partner.city) + '&zip=' + quote(partner.zip)
|
||||
_logger.info(url)
|
||||
try:
|
||||
response = urlopen(url, context=_create_unverified_context())
|
||||
response_body = response.read()
|
||||
_logger.info(response_body)
|
||||
except HTTPError as e:
|
||||
_logger.warn('Error on request: ' + str(e))
|
||||
response_body = ''
|
||||
|
||||
return self._parse_rate(response_body)
|
||||
|
||||
def is_success(self, result):
|
||||
'''
|
||||
ADDRESS = 0,
|
||||
LATLON = 0,
|
||||
PLUS4 = 1,
|
||||
ADDRESS_STANARDIZED = 2,
|
||||
PLUS4_STANARDIZED = 3,
|
||||
ADDRESS_CHANGED = 4,
|
||||
ZIPCODE = 5,
|
||||
ADDRESS_NOT_FOUND = 6,
|
||||
LATLON_NOT_FOUND = 7,
|
||||
POI = 8,
|
||||
ERROR = 9
|
||||
internal parse_error = 100
|
||||
'''
|
||||
if 'result_code' not in result or result['result_code'] >= 9 or result['result_code'] < 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _parse_rate(self, response_body):
|
||||
# 'LocationCode=1704 Rate=0.100 ResultCode=0'
|
||||
# {
|
||||
# 'result_code': 0,
|
||||
# 'location_code': '1704',
|
||||
# 'rate': '10.00',
|
||||
# }
|
||||
|
||||
res = {'result_code': 100}
|
||||
if len(response_body) > 200:
|
||||
# this likely means that they returned an HTML page
|
||||
return res
|
||||
|
||||
body_parts = response_body.decode().split(' ')
|
||||
for part in body_parts:
|
||||
if part.find('ResultCode=') >= 0:
|
||||
res['result_code'] = int(part[len('ResultCode='):])
|
||||
elif part.find('Rate=') >= 0:
|
||||
res['rate'] = '%.2f' % (float(part[len('Rate='):]) * 100.0)
|
||||
elif part.find('LocationCode=') >= 0:
|
||||
res['location_code'] = part[len('LocationCode='):]
|
||||
elif part.find('debughint=') >= 0:
|
||||
res['debug_hint'] = part[len('debughint='):]
|
||||
return res
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="account_position_us_wa_inherit_from_view" model="ir.ui.view">
|
||||
<field name="name">account.fiscal.position.form.inherit</field>
|
||||
<field name="model">account.fiscal.position</field>
|
||||
<field name="inherit_id" ref="account.view_account_position_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='active']" position="after">
|
||||
<field name="is_us_wa"/>
|
||||
<field name="wa_base_tax_id" attrs="{'invisible': [('is_us_wa', '=', False)]}" />
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_tax_form" model="ir.ui.view">
|
||||
<field name="name">account.tax.form.inherit</field>
|
||||
<field name="model">account.tax</field>
|
||||
<field name="inherit_id" ref="account.view_tax_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='description']" position="after">
|
||||
<field name="wa_location_code" />
|
||||
<field name="wa_location_zips" />
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
1
connector_magento_product_by_sku
Symbolic link
1
connector_magento_product_by_sku
Symbolic link
@@ -0,0 +1 @@
|
||||
external/hibou-oca/connector-magento/connector_magento_product_by_sku
|
||||
@@ -5,14 +5,14 @@
|
||||
<record model="ir.cron" id="ir_cron_import_sale_orders" forcecreate="True">
|
||||
<field name="name">Walmart - Import Sales Orders</field>
|
||||
<field eval="False" name="active"/>
|
||||
<field name="state">code</field>
|
||||
<field name="user_id" ref="base.user_root"/>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field eval="False" name="doall"/>
|
||||
<field ref="connector_walmart.model_walmart_backend" name="model_id"/>
|
||||
<field eval="'_scheduler_import_sale_orders'" name="function"/>
|
||||
<field eval="'()'" name="args"/>
|
||||
<field name="code">model._scheduler_import_sale_orders()</field>
|
||||
</record>
|
||||
|
||||
<record id="excep_wrong_total_amount" model="exception.rule">
|
||||
|
||||
@@ -11,7 +11,7 @@ from ...components.api.walmart import Walmart
|
||||
|
||||
_logger = getLogger(__name__)
|
||||
|
||||
IMPORT_DELTA_BUFFER = 60 # seconds
|
||||
IMPORT_DELTA_BUFFER = 600 # seconds
|
||||
|
||||
|
||||
class WalmartBackend(models.Model):
|
||||
|
||||
1
delivery_hibou/__init__.py
Normal file
1
delivery_hibou/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
24
delivery_hibou/__manifest__.py
Normal file
24
delivery_hibou/__manifest__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
'name': 'Delivery Hibou',
|
||||
'summary': 'Adds underlying pinnings for things like "RMA Return Labels"',
|
||||
'version': '11.0.1.0.0',
|
||||
'author': "Hibou Corp.",
|
||||
'category': 'Stock',
|
||||
'license': 'AGPL-3',
|
||||
'images': [],
|
||||
'website': "https://hibou.io",
|
||||
'description': """
|
||||
This is a collection of "typical" carrier needs, and a bridge into Hibou modules like `delivery_partner` and `sale_planner`.
|
||||
""",
|
||||
'depends': [
|
||||
'delivery',
|
||||
'delivery_partner',
|
||||
],
|
||||
'demo': [],
|
||||
'data': [
|
||||
'views/delivery_views.xml',
|
||||
'views/stock_views.xml',
|
||||
],
|
||||
'auto_install': False,
|
||||
'installable': True,
|
||||
}
|
||||
2
delivery_hibou/models/__init__.py
Normal file
2
delivery_hibou/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import delivery
|
||||
from . import stock
|
||||
152
delivery_hibou/models/delivery.py
Normal file
152
delivery_hibou/models/delivery.py
Normal file
@@ -0,0 +1,152 @@
|
||||
from odoo import fields, models
|
||||
from odoo.addons.stock.models.stock_move import PROCUREMENT_PRIORITIES
|
||||
|
||||
|
||||
class DeliveryCarrier(models.Model):
|
||||
_inherit = 'delivery.carrier'
|
||||
|
||||
automatic_insurance_value = fields.Float(string='Automatic Insurance Value',
|
||||
help='Will be used during shipping to determine if the '
|
||||
'picking\'s value warrants insurance being added.')
|
||||
procurement_priority = fields.Selection(PROCUREMENT_PRIORITIES,
|
||||
string='Procurement Priority',
|
||||
help='Priority for this carrier. Will affect pickings '
|
||||
'and procurements related to this carrier.')
|
||||
|
||||
def get_insurance_value(self, order=None, picking=None):
|
||||
value = 0.0
|
||||
if order:
|
||||
if order.order_line:
|
||||
value = sum(order.order_line.filtered(lambda l: l.type != 'service').mapped('price_subtotal'))
|
||||
else:
|
||||
return value
|
||||
if picking:
|
||||
value = picking.declared_value()
|
||||
if picking.require_insurance == 'no':
|
||||
value = 0.0
|
||||
elif picking.require_insurance == 'auto' and self.automatic_insurance_value and self.automatic_insurance_value > value:
|
||||
value = 0.0
|
||||
return value
|
||||
|
||||
def get_third_party_account(self, order=None, picking=None):
|
||||
if order and order.shipping_account_id:
|
||||
return order.shipping_account_id
|
||||
if picking and picking.shipping_account_id:
|
||||
return picking.shipping_account_id
|
||||
return None
|
||||
|
||||
def get_order_name(self, order=None, picking=None):
|
||||
if order:
|
||||
return order.name
|
||||
if picking:
|
||||
if picking.sale_id:
|
||||
return picking.sale_id.name # + ' - ' + picking.name
|
||||
return picking.name
|
||||
return ''
|
||||
|
||||
def get_attn(self, order=None, picking=None):
|
||||
if order:
|
||||
return order.client_order_ref
|
||||
if picking and picking.sale_id:
|
||||
return picking.sale_id.client_order_ref
|
||||
# If Picking has a reference, decide what it is.
|
||||
return False
|
||||
|
||||
def _classify_picking(self, picking):
|
||||
if picking.picking_type_id.code == 'incoming' and picking.location_id.usage == 'supplier' and picking.location_dest_id.usage == 'customer':
|
||||
return 'dropship'
|
||||
elif picking.picking_type_id.code == 'incoming' and picking.location_id.usage == 'customer' and picking.location_dest_id.usage == 'supplier':
|
||||
return 'dropship_in'
|
||||
elif picking.picking_type_id.code == 'incoming':
|
||||
return 'in'
|
||||
return 'out'
|
||||
|
||||
# Shipper Company
|
||||
|
||||
def get_shipper_company(self, order=None, picking=None):
|
||||
"""
|
||||
Shipper Company: The `res.partner` that provides the name of where the shipment is coming from.
|
||||
"""
|
||||
if order:
|
||||
return order.company_id.partner_id
|
||||
if picking:
|
||||
return getattr(self, ('_get_shipper_company_%s' % (self._classify_picking(picking),)),
|
||||
self._get_shipper_company_out)(picking)
|
||||
return None
|
||||
|
||||
def _get_shipper_company_dropship(self, picking):
|
||||
return picking.company_id.partner_id
|
||||
|
||||
def _get_shipper_company_dropship_in(self, picking):
|
||||
return picking.company_id.partner_id
|
||||
|
||||
def _get_shipper_company_in(self, picking):
|
||||
return picking.company_id.partner_id
|
||||
|
||||
def _get_shipper_company_out(self, picking):
|
||||
return picking.company_id.partner_id
|
||||
|
||||
# Shipper Warehouse
|
||||
|
||||
def get_shipper_warehouse(self, order=None, picking=None):
|
||||
"""
|
||||
Shipper Warehouse: The `res.partner` that is basically the physical address a shipment is coming from.
|
||||
"""
|
||||
if order:
|
||||
return order.warehouse_id.partner_id
|
||||
if picking:
|
||||
return getattr(self, ('_get_shipper_warehouse_%s' % (self._classify_picking(picking),)),
|
||||
self._get_shipper_warehouse_out)(picking)
|
||||
return None
|
||||
|
||||
def _get_shipper_warehouse_dropship(self, picking):
|
||||
return picking.partner_id
|
||||
|
||||
def _get_shipper_warehouse_dropship_in(self, picking):
|
||||
if picking.sale_id:
|
||||
picking.sale_id.partner_shipping_id
|
||||
return self._get_shipper_warehouse_dropship_in_no_sale(picking)
|
||||
|
||||
def _get_shipper_warehouse_dropship_in_no_sale(self, picking):
|
||||
return picking.company_id.partner_id
|
||||
|
||||
def _get_shipper_warehouse_in(self, picking):
|
||||
return picking.partner_id
|
||||
|
||||
def _get_shipper_warehouse_out(self, picking):
|
||||
return picking.picking_type_id.warehouse_id.partner_id
|
||||
|
||||
# Recipient
|
||||
|
||||
def get_recipient(self, order=None, picking=None):
|
||||
"""
|
||||
Recipient: The `res.partner` receiving the shipment.
|
||||
"""
|
||||
if order:
|
||||
return order.partner_shipping_id
|
||||
if picking:
|
||||
return getattr(self, ('_get_recipient_%s' % (self._classify_picking(picking),)),
|
||||
self._get_recipient_out)(picking)
|
||||
return None
|
||||
|
||||
def _get_recipient_dropship(self, picking):
|
||||
if picking.sale_id:
|
||||
return picking.sale_id.partner_shipping_id
|
||||
return picking.sale_id.partner_shipping_id
|
||||
|
||||
def _get_recipient_dropship_no_sale(self, picking):
|
||||
return picking.company_id.partner_id
|
||||
|
||||
def _get_recipient_dropship_in(self, picking):
|
||||
return picking.picking_type_id.warehouse_id.partner_id
|
||||
|
||||
def _get_recipient_in(self, picking):
|
||||
return picking.picking_type_id.warehouse_id.partner_id
|
||||
|
||||
def _get_recipient_out(self, picking):
|
||||
return picking.partner_id
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
50
delivery_hibou/models/stock.py
Normal file
50
delivery_hibou/models/stock.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class StockPicking(models.Model):
|
||||
_inherit = 'stock.picking'
|
||||
|
||||
shipping_account_id = fields.Many2one('partner.shipping.account', string='Shipping Account')
|
||||
require_insurance = fields.Selection([
|
||||
('auto', 'Automatic'),
|
||||
('yes', 'Yes'),
|
||||
('no', 'No'),
|
||||
], string='Require Insurance', default='auto',
|
||||
help='If your carrier supports it, auto should be calculated off of the "Automatic Insurance Value" field.')
|
||||
|
||||
@api.one
|
||||
@api.depends('move_lines.priority', 'carrier_id')
|
||||
def _compute_priority(self):
|
||||
if self.carrier_id.procurement_priority:
|
||||
self.priority = self.carrier_id.procurement_priority
|
||||
else:
|
||||
super(StockPicking, self)._compute_priority()
|
||||
|
||||
@api.model
|
||||
def create(self, values):
|
||||
origin = values.get('origin')
|
||||
if origin and not values.get('shipping_account_id'):
|
||||
so = self.env['sale.order'].search([('name', '=', str(origin))], limit=1)
|
||||
if so and so.shipping_account_id:
|
||||
values['shipping_account_id'] = so.shipping_account_id.id
|
||||
|
||||
res = super(StockPicking, self).create(values)
|
||||
return res
|
||||
|
||||
def declared_value(self):
|
||||
self.ensure_one()
|
||||
cost = sum([(l.product_id.standard_price * l.qty_done) for l in self.move_line_ids] or [0.0])
|
||||
if not cost:
|
||||
# Assume Full Value
|
||||
cost = sum([(l.product_id.standard_price * l.product_uom_qty) for l in self.move_lines] or [0.0])
|
||||
return cost
|
||||
|
||||
|
||||
|
||||
class StockMove(models.Model):
|
||||
_inherit = 'stock.move'
|
||||
|
||||
def _prepare_procurement_values(self):
|
||||
res = super(StockMove, self)._prepare_procurement_values()
|
||||
res['priority'] = self.picking_id.priority or self.priority
|
||||
return res
|
||||
1
delivery_hibou/tests/__init__.py
Normal file
1
delivery_hibou/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_delivery_hibou
|
||||
160
delivery_hibou/tests/test_delivery_hibou.py
Normal file
160
delivery_hibou/tests/test_delivery_hibou.py
Normal file
@@ -0,0 +1,160 @@
|
||||
from odoo.tests import common
|
||||
|
||||
|
||||
class TestDeliveryHibou(common.TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestDeliveryHibou, self).setUp()
|
||||
self.partner = self.env.ref('base.res_partner_address_13')
|
||||
self.product = self.env.ref('product.product_product_7')
|
||||
# Create Shipping Account
|
||||
self.shipping_account = self.env['partner.shipping.account'].create({
|
||||
'name': '123123',
|
||||
'delivery_type': 'other',
|
||||
})
|
||||
# Create Carrier
|
||||
self.delivery_product = self.env['product.product'].create({
|
||||
'name': 'Test Carrier1 Delivery',
|
||||
'type': 'service',
|
||||
})
|
||||
self.carrier = self.env['delivery.carrier'].create({
|
||||
'name': 'Test Carrier1',
|
||||
'product_id': self.delivery_product.id,
|
||||
})
|
||||
|
||||
def test_delivery_hibou(self):
|
||||
# Assign a new shipping account
|
||||
self.partner.shipping_account_id = self.shipping_account
|
||||
|
||||
# Assign values to new Carrier
|
||||
test_insurance_value = 600
|
||||
test_procurement_priority = '2'
|
||||
self.carrier.automatic_insurance_value = test_insurance_value
|
||||
self.carrier.procurement_priority = test_procurement_priority
|
||||
|
||||
|
||||
sale_order = self.env['sale.order'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'partner_shipping_id': self.partner.id,
|
||||
'partner_invoice_id': self.partner.id,
|
||||
'carrier_id': self.carrier.id,
|
||||
'shipping_account_id': self.shipping_account.id,
|
||||
'order_line': [(0, 0, {
|
||||
'product_id': self.product.id,
|
||||
})]
|
||||
})
|
||||
sale_order.get_delivery_price()
|
||||
sale_order.set_delivery_line()
|
||||
sale_order.action_confirm()
|
||||
# Make sure 3rd party Shipping Account is set.
|
||||
self.assertEqual(sale_order.shipping_account_id, self.shipping_account)
|
||||
|
||||
self.assertTrue(sale_order.picking_ids)
|
||||
# Priority coming from Carrier procurement_priority
|
||||
self.assertEqual(sale_order.picking_ids.priority, test_procurement_priority)
|
||||
# 3rd party Shipping Account copied from Sale Order
|
||||
self.assertEqual(sale_order.picking_ids.shipping_account_id, self.shipping_account)
|
||||
self.assertEqual(sale_order.carrier_id.get_third_party_account(order=sale_order), self.shipping_account)
|
||||
|
||||
# Test attn
|
||||
test_ref = 'TEST100'
|
||||
self.assertEqual(sale_order.carrier_id.get_attn(order=sale_order), False)
|
||||
sale_order.client_order_ref = test_ref
|
||||
self.assertEqual(sale_order.carrier_id.get_attn(order=sale_order), test_ref)
|
||||
# The picking should get this ref as well
|
||||
self.assertEqual(sale_order.picking_ids.carrier_id.get_attn(picking=sale_order.picking_ids), test_ref)
|
||||
|
||||
# Test order_name
|
||||
self.assertEqual(sale_order.carrier_id.get_order_name(order=sale_order), sale_order.name)
|
||||
# The picking should get the same 'order_name'
|
||||
self.assertEqual(sale_order.picking_ids.carrier_id.get_order_name(picking=sale_order.picking_ids), sale_order.name)
|
||||
|
||||
def test_carrier_hibou_out(self):
|
||||
test_insurance_value = 4000
|
||||
self.carrier.automatic_insurance_value = test_insurance_value
|
||||
|
||||
picking_out = self.env.ref('stock.outgoing_shipment_main_warehouse')
|
||||
self.assertEqual(picking_out.state, 'assigned')
|
||||
|
||||
picking_out.carrier_id = self.carrier
|
||||
|
||||
# This relies heavily on the 'stock' demo data.
|
||||
# Should only have a single move_line_ids and it should not be done at all.
|
||||
self.assertEqual(picking_out.move_line_ids.mapped('qty_done'), [0.0])
|
||||
self.assertEqual(picking_out.move_line_ids.mapped('product_uom_qty'), [15.0])
|
||||
self.assertEqual(picking_out.move_line_ids.mapped('product_id.standard_price'), [3300.0])
|
||||
|
||||
# The 'value' is assumed to be all of the product value from the initial demand.
|
||||
self.assertEqual(picking_out.declared_value(), 15.0 * 3300.0)
|
||||
self.assertEqual(picking_out.carrier_id.get_insurance_value(picking=picking_out), picking_out.declared_value())
|
||||
|
||||
# Workflow where user explicitly opts out of insurance on the picking level.
|
||||
picking_out.require_insurance = 'no'
|
||||
self.assertEqual(picking_out.carrier_id.get_insurance_value(picking=picking_out), 0.0)
|
||||
picking_out.require_insurance = 'auto'
|
||||
|
||||
# Lets choose to only delivery one piece at the moment.
|
||||
# This does not meet the minimum on the carrier to have insurance value.
|
||||
picking_out.move_line_ids.qty_done = 1.0
|
||||
self.assertEqual(picking_out.declared_value(), 3300.0)
|
||||
self.assertEqual(picking_out.carrier_id.get_insurance_value(picking=picking_out), 0.0)
|
||||
# Workflow where user opts in to insurance.
|
||||
picking_out.require_insurance = 'yes'
|
||||
self.assertEqual(picking_out.carrier_id.get_insurance_value(picking=picking_out), 3300.0)
|
||||
picking_out.require_insurance = 'auto'
|
||||
|
||||
# Test with picking having 3rd party account.
|
||||
self.assertEqual(picking_out.carrier_id.get_third_party_account(picking=picking_out), None)
|
||||
picking_out.shipping_account_id = self.shipping_account
|
||||
self.assertEqual(picking_out.carrier_id.get_third_party_account(picking=picking_out), self.shipping_account)
|
||||
|
||||
# Shipment Time Methods!
|
||||
self.assertEqual(picking_out.carrier_id._classify_picking(picking=picking_out), 'out')
|
||||
self.assertEqual(picking_out.carrier_id.get_shipper_company(picking=picking_out),
|
||||
picking_out.company_id.partner_id)
|
||||
self.assertEqual(picking_out.carrier_id.get_shipper_warehouse(picking=picking_out),
|
||||
picking_out.picking_type_id.warehouse_id.partner_id)
|
||||
self.assertEqual(picking_out.carrier_id.get_recipient(picking=picking_out),
|
||||
picking_out.partner_id)
|
||||
# This picking has no `sale_id`
|
||||
# Right now ATTN requires a sale_id, which this picking doesn't have (none of the stock ones do)
|
||||
self.assertEqual(picking_out.carrier_id.get_attn(picking=picking_out), False)
|
||||
self.assertEqual(picking_out.carrier_id.get_order_name(picking=picking_out), picking_out.name)
|
||||
|
||||
def test_carrier_hibou_in(self):
|
||||
picking_in = self.env.ref('stock.incomming_shipment1')
|
||||
self.assertEqual(picking_in.state, 'assigned')
|
||||
|
||||
picking_in.carrier_id = self.carrier
|
||||
# This relies heavily on the 'stock' demo data.
|
||||
# Should only have a single move_line_ids and it should not be done at all.
|
||||
self.assertEqual(picking_in.move_line_ids.mapped('qty_done'), [0.0])
|
||||
self.assertEqual(picking_in.move_line_ids.mapped('product_uom_qty'), [35.0])
|
||||
self.assertEqual(picking_in.move_line_ids.mapped('product_id.standard_price'), [55.0])
|
||||
|
||||
self.assertEqual(picking_in.carrier_id._classify_picking(picking=picking_in), 'in')
|
||||
self.assertEqual(picking_in.carrier_id.get_shipper_company(picking=picking_in),
|
||||
picking_in.company_id.partner_id)
|
||||
self.assertEqual(picking_in.carrier_id.get_shipper_warehouse(picking=picking_in),
|
||||
picking_in.partner_id)
|
||||
self.assertEqual(picking_in.carrier_id.get_recipient(picking=picking_in),
|
||||
picking_in.picking_type_id.warehouse_id.partner_id)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
14
delivery_hibou/views/delivery_views.xml
Normal file
14
delivery_hibou/views/delivery_views.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="view_delivery_carrier_form" model="ir.ui.view">
|
||||
<field name="name">hibou.delivery.carrier.form</field>
|
||||
<field name="model">delivery.carrier</field>
|
||||
<field name="inherit_id" ref="delivery.view_delivery_carrier_form" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='integration_level']" position="after">
|
||||
<field name="automatic_insurance_value"/>
|
||||
<field name="procurement_priority"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
16
delivery_hibou/views/stock_views.xml
Normal file
16
delivery_hibou/views/stock_views.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_picking_withcarrier_out_form" model="ir.ui.view">
|
||||
<field name="name">hibou.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="//field[@name='carrier_id']" position="before">
|
||||
<field name="require_insurance" attrs="{'readonly': [('state', 'in', ('done', 'cancel'))]}"/>
|
||||
<field name="shipping_account_id" attrs="{'readonly': [('state', 'in', ('done', 'cancel'))]}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
1
delivery_stamps/__init__.py
Normal file
1
delivery_stamps/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
26
delivery_stamps/__manifest__.py
Normal file
26
delivery_stamps/__manifest__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
'name': 'Stamps.com (USPS) Shipping',
|
||||
'summary': 'Send your shippings through Stamps.com and track them online.',
|
||||
'version': '11.0.1.0.0',
|
||||
'author': "Hibou Corp.",
|
||||
'category': 'Warehouse',
|
||||
'license': 'AGPL-3',
|
||||
'images': [],
|
||||
'website': "https://hibou.io",
|
||||
'description': """
|
||||
Stamps.com (USPS) Shipping
|
||||
==========================
|
||||
|
||||
Send your shippings through Stamps.com and track them online.
|
||||
|
||||
""",
|
||||
'depends': [
|
||||
'delivery',
|
||||
],
|
||||
'demo': [],
|
||||
'data': [
|
||||
'views/delivery_stamps_view.xml',
|
||||
],
|
||||
'auto_install': False,
|
||||
'installable': True,
|
||||
}
|
||||
1
delivery_stamps/models/__init__.py
Normal file
1
delivery_stamps/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import delivery_stamps
|
||||
31
delivery_stamps/models/api/LICENSE
Executable file
31
delivery_stamps/models/api/LICENSE
Executable file
@@ -0,0 +1,31 @@
|
||||
Copyright (c) 2014 by Jonathan Zempel.
|
||||
|
||||
Some rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following
|
||||
disclaimer in the documentation and/or other materials provided
|
||||
with the distribution.
|
||||
|
||||
* The names of the contributors may not be used to endorse or
|
||||
promote products derived from this software without specific
|
||||
prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
14
delivery_stamps/models/api/__init__.py
Executable file
14
delivery_stamps/models/api/__init__.py
Executable file
@@ -0,0 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
stamps
|
||||
~~~~~~
|
||||
|
||||
Stamps.com API.
|
||||
|
||||
:copyright: 2014 by Jonathan Zempel.
|
||||
:license: BSD, see LICENSE for more details.
|
||||
"""
|
||||
|
||||
__author__ = "Jonathan Zempel"
|
||||
__license__ = "BSD"
|
||||
__version__ = "0.9.1"
|
||||
102
delivery_stamps/models/api/config.py
Executable file
102
delivery_stamps/models/api/config.py
Executable file
@@ -0,0 +1,102 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
stamps.config
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
Stamps.com configuration.
|
||||
|
||||
:copyright: 2014 by Jonathan Zempel.
|
||||
:license: BSD, see LICENSE for more details.
|
||||
"""
|
||||
|
||||
from configparser import NoOptionError, NoSectionError, SafeConfigParser
|
||||
from urllib.request import pathname2url
|
||||
from urllib.parse import urljoin
|
||||
import os
|
||||
|
||||
|
||||
VERSION = 49
|
||||
|
||||
|
||||
class StampsConfiguration(object):
|
||||
"""Stamps service configuration. The service configuration may be provided
|
||||
directly via parameter values, or it can be read from a configuration file.
|
||||
If no parameters are given, the configuration will attempt to read from a
|
||||
``'.stamps.cfg'`` file in the user's HOME directory. Alternately, a
|
||||
configuration filename can be passed to the constructor.
|
||||
|
||||
Here is a sample configuration (by default the constructor reads from a
|
||||
``'default'`` section)::
|
||||
|
||||
[default]
|
||||
integration_id = XXXXXXXX-1111-2222-3333-YYYYYYYYYYYY
|
||||
username = stampy
|
||||
password = secret
|
||||
|
||||
:param integration_id: Default `None`. Unique ID, provided by Stamps.com,
|
||||
that represents your application.
|
||||
:param username: Default `None`. Stamps.com account username.
|
||||
:param password: Default `None`. Stamps.com password.
|
||||
:param wsdl: Default `None`. WSDL URI. Use ``'testing'`` to use the test
|
||||
server WSDL.
|
||||
:param port: Default `None`. The name of the WSDL port to use.
|
||||
:param file_name: Default `None`. Optional configuration file name.
|
||||
:param section: Default ``'default'``. The configuration section to use.
|
||||
"""
|
||||
|
||||
def __init__(self, integration_id=None, username=None, password=None,
|
||||
wsdl=None, port=None, file_name=None, section="default"):
|
||||
parser = SafeConfigParser()
|
||||
|
||||
if file_name:
|
||||
parser.read([file_name])
|
||||
else:
|
||||
parser.read([os.path.expanduser("~/.stamps.cfg")])
|
||||
|
||||
self.integration_id = self.__get(parser, section, "integration_id",
|
||||
integration_id)
|
||||
self.username = self.__get(parser, section, "username", username)
|
||||
self.password = self.__get(parser, section, "password", password)
|
||||
self.wsdl = self.__get(parser, section, "wsdl", wsdl)
|
||||
self.port = self.__get(parser, section, "port", port)
|
||||
|
||||
if self.wsdl is None or wsdl == "testing":
|
||||
file_path = os.path.abspath(__file__)
|
||||
directory_path = os.path.dirname(file_path)
|
||||
|
||||
if wsdl == "testing":
|
||||
file_name = "stamps_v{0}.test.wsdl".format(VERSION)
|
||||
else:
|
||||
file_name = "stamps_v{0}.wsdl".format(VERSION)
|
||||
|
||||
wsdl = os.path.join(directory_path, "wsdls", file_name)
|
||||
self.wsdl = urljoin("file:", pathname2url(wsdl))
|
||||
|
||||
if self.port is None:
|
||||
self.port = "SwsimV{0}Soap12".format(VERSION)
|
||||
|
||||
assert self.integration_id
|
||||
assert self.username
|
||||
assert self.password
|
||||
assert self.wsdl
|
||||
assert self.port
|
||||
|
||||
@staticmethod
|
||||
def __get(parser, section, name, default):
|
||||
"""Get a configuration value for the named section.
|
||||
|
||||
:param parser: The configuration parser.
|
||||
:param section: The section for the given name.
|
||||
:param name: The name of the value to retrieve.
|
||||
"""
|
||||
if default:
|
||||
vars = {name: default}
|
||||
else:
|
||||
vars = None
|
||||
|
||||
try:
|
||||
ret_val = parser.get(section, name, vars=vars)
|
||||
except (NoSectionError, NoOptionError):
|
||||
ret_val = default
|
||||
|
||||
return ret_val
|
||||
298
delivery_stamps/models/api/services.py
Executable file
298
delivery_stamps/models/api/services.py
Executable file
@@ -0,0 +1,298 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
stamps.services
|
||||
~~~~~~~~~~~~~~~
|
||||
|
||||
Stamps.com services.
|
||||
|
||||
:copyright: 2014 by Jonathan Zempel.
|
||||
:license: BSD, see LICENSE for more details.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from logging import getLogger
|
||||
from re import compile
|
||||
from suds import WebFault
|
||||
from suds.bindings.document import Document
|
||||
from suds.client import Client
|
||||
from suds.plugin import MessagePlugin
|
||||
from suds.sax.element import Element
|
||||
from suds.sudsobject import asdict
|
||||
from suds.xsd.sxbase import XBuiltin
|
||||
from suds.xsd.sxbuiltin import Factory
|
||||
|
||||
|
||||
PATTERN_HEX = r"[0-9a-fA-F]"
|
||||
PATTERN_ID = r"{hex}{{8}}-{hex}{{4}}-{hex}{{4}}-{hex}{{4}}-{hex}{{12}}".format(
|
||||
hex=PATTERN_HEX)
|
||||
RE_TRANSACTION_ID = compile(PATTERN_ID)
|
||||
|
||||
|
||||
class AuthenticatorPlugin(MessagePlugin):
|
||||
"""Handle message authentication.
|
||||
|
||||
:param credentials: Stamps API credentials.
|
||||
:param wsdl: Configured service client.
|
||||
"""
|
||||
|
||||
def __init__(self, credentials, client):
|
||||
self.credentials = credentials
|
||||
self.client = client
|
||||
self.authenticator = None
|
||||
|
||||
def marshalled(self, context):
|
||||
"""Add an authenticator token to the document before it is sent.
|
||||
|
||||
:param context: The current message context.
|
||||
"""
|
||||
body = context.envelope.getChild("Body")
|
||||
operation = body[0]
|
||||
|
||||
if operation.name in ("AuthenticateUser", "RegisterAccount"):
|
||||
pass
|
||||
elif self.authenticator:
|
||||
namespace = operation.namespace()
|
||||
element = Element("Authenticator", ns=namespace)
|
||||
element.setText(self.authenticator)
|
||||
operation.insert(element)
|
||||
else:
|
||||
document = Document(self.client.wsdl)
|
||||
method = self.client.service.AuthenticateUser.method
|
||||
parameter = document.param_defs(method)[0]
|
||||
element = document.mkparam(method, parameter, self.credentials)
|
||||
operation.insert(element)
|
||||
|
||||
def unmarshalled(self, context):
|
||||
"""Store the authenticator token for the next call.
|
||||
|
||||
:param context: The current message context.
|
||||
"""
|
||||
if hasattr(context.reply, "Authenticator"):
|
||||
self.authenticator = context.reply.Authenticator
|
||||
del context.reply.Authenticator
|
||||
else:
|
||||
self.authenticator = None
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class BaseService(object):
|
||||
"""Base service.
|
||||
|
||||
:param configuration: API configuration.
|
||||
"""
|
||||
|
||||
def __init__(self, configuration):
|
||||
Factory.maptag("decimal", XDecimal)
|
||||
self.client = Client(configuration.wsdl)
|
||||
credentials = self.create("Credentials")
|
||||
credentials.IntegrationID = configuration.integration_id
|
||||
credentials.Username = configuration.username
|
||||
credentials.Password = configuration.password
|
||||
self.plugin = AuthenticatorPlugin(credentials, self.client)
|
||||
self.client.set_options(plugins=[self.plugin], port=configuration.port)
|
||||
self.logger = getLogger("stamps")
|
||||
|
||||
def call(self, method, **kwargs):
|
||||
"""Call the given web service method.
|
||||
|
||||
:param method: The name of the web service operation to call.
|
||||
:param kwargs: Method keyword-argument parameters.
|
||||
"""
|
||||
self.logger.debug("%s(%s)", method, kwargs)
|
||||
instance = getattr(self.client.service, method)
|
||||
|
||||
try:
|
||||
ret_val = instance(**kwargs)
|
||||
except WebFault as error:
|
||||
self.logger.warning("Retry %s", method, exc_info=True)
|
||||
self.plugin.authenticator = None
|
||||
|
||||
try: # retry with a re-authenticated user.
|
||||
ret_val = instance(**kwargs)
|
||||
except WebFault as error:
|
||||
self.logger.exception("%s retry failed", method)
|
||||
self.plugin.authenticator = None
|
||||
raise error
|
||||
|
||||
return ret_val
|
||||
|
||||
def create(self, wsdl_type):
|
||||
"""Create an object of the given WSDL type.
|
||||
|
||||
:param wsdl_type: The WSDL type to create an object for.
|
||||
"""
|
||||
return self.client.factory.create(wsdl_type)
|
||||
|
||||
|
||||
class StampsService(BaseService):
|
||||
"""Stamps.com service.
|
||||
"""
|
||||
|
||||
def add_postage(self, amount, transaction_id=None):
|
||||
"""Add postage to the account.
|
||||
|
||||
:param amount: The amount of postage to purchase.
|
||||
:param transaction_id: Default `None`. ID that may be used to retry the
|
||||
purchase of this postage.
|
||||
"""
|
||||
account = self.get_account()
|
||||
control = account.AccountInfo.PostageBalance.ControlTotal
|
||||
|
||||
return self.call("PurchasePostage", PurchaseAmount=amount,
|
||||
ControlTotal=control, IntegratorTxID=transaction_id)
|
||||
|
||||
def create_add_on(self):
|
||||
"""Create a new add-on object.
|
||||
"""
|
||||
return self.create("AddOnV7")
|
||||
|
||||
def create_customs(self):
|
||||
"""Create a new customs object.
|
||||
"""
|
||||
return self.create("CustomsV3")
|
||||
|
||||
def create_array_of_customs_lines(self):
|
||||
"""Create a new array of customs objects.
|
||||
"""
|
||||
return self.create("ArrayOfCustomsLine")
|
||||
|
||||
def create_customs_lines(self):
|
||||
"""Create new customs lines.
|
||||
"""
|
||||
return self.create("CustomsLine")
|
||||
|
||||
def create_address(self):
|
||||
"""Create a new address object.
|
||||
"""
|
||||
return self.create("Address")
|
||||
|
||||
def create_purchase_status(self):
|
||||
"""Create a new purchase status object.
|
||||
"""
|
||||
return self.create("PurchaseStatus")
|
||||
|
||||
def create_registration(self):
|
||||
"""Create a new registration object.
|
||||
"""
|
||||
ret_val = self.create("RegisterAccount")
|
||||
ret_val.IntegrationID = self.plugin.credentials.IntegrationID
|
||||
ret_val.UserName = self.plugin.credentials.Username
|
||||
ret_val.Password = self.plugin.credentials.Password
|
||||
|
||||
return ret_val
|
||||
|
||||
def create_shipping(self):
|
||||
"""Create a new shipping object.
|
||||
"""
|
||||
return self.create("RateV18")
|
||||
|
||||
def get_address(self, address):
|
||||
"""Get a shipping address.
|
||||
|
||||
:param address: Address instance to get a clean shipping address for.
|
||||
"""
|
||||
return self.call("CleanseAddress", Address=address)
|
||||
|
||||
def get_account(self):
|
||||
"""Get account information.
|
||||
"""
|
||||
return self.call("GetAccountInfo")
|
||||
|
||||
def get_label(self, from_address, to_address, rate, transaction_id, image_type=None,
|
||||
customs=None, sample=False):
|
||||
"""Get a shipping label.
|
||||
|
||||
:param from_address: The shipping 'from' address.
|
||||
:param to_address: The shipping 'to' address.
|
||||
:param rate: A rate instance for the shipment.
|
||||
:param transaction_id: ID that may be used to retry/rollback the
|
||||
purchase of this label.
|
||||
:param customs: A customs instance for international shipments.
|
||||
:param sample: Default ``False``. Get a sample label without postage.
|
||||
"""
|
||||
return self.call("CreateIndicium", IntegratorTxID=transaction_id,
|
||||
Rate=rate, From=from_address, To=to_address, ImageType=image_type, Customs=customs,
|
||||
SampleOnly=sample)
|
||||
|
||||
def get_postage_status(self, transaction_id):
|
||||
"""Get postage purchase status.
|
||||
|
||||
:param transaction_id: The transaction ID returned by
|
||||
:meth:`add_postage`.
|
||||
"""
|
||||
return self.call("GetPurchaseStatus", TransactionID=transaction_id)
|
||||
|
||||
def get_rates(self, shipping):
|
||||
"""Get shipping rates.
|
||||
|
||||
:param shipping: Shipping instance to get rates for.
|
||||
"""
|
||||
rates = self.call("GetRates", Rate=shipping)
|
||||
|
||||
if rates.Rates:
|
||||
ret_val = [rate for rate in rates.Rates.Rate]
|
||||
else:
|
||||
ret_val = []
|
||||
|
||||
return ret_val
|
||||
|
||||
def get_tracking(self, transaction_id):
|
||||
"""Get tracking events for a shipment.
|
||||
|
||||
:param transaction_id: The transaction ID (or tracking number) returned
|
||||
by :meth:`get_label`.
|
||||
"""
|
||||
if RE_TRANSACTION_ID.match(transaction_id):
|
||||
arguments = dict(StampsTxID=transaction_id)
|
||||
else:
|
||||
arguments = dict(TrackingNumber=transaction_id)
|
||||
|
||||
return self.call("TrackShipment", **arguments)
|
||||
|
||||
def register_account(self, registration):
|
||||
"""Register a new account.
|
||||
|
||||
:param registration: Registration instance.
|
||||
"""
|
||||
arguments = asdict(registration)
|
||||
|
||||
return self.call("RegisterAccount", **arguments)
|
||||
|
||||
def remove_label(self, transaction_id):
|
||||
"""Cancel a shipping label.
|
||||
|
||||
:param transaction_id: The transaction ID (or tracking number) returned
|
||||
by :meth:`get_label`.
|
||||
"""
|
||||
if RE_TRANSACTION_ID.match(transaction_id):
|
||||
arguments = dict(StampsTxID=transaction_id)
|
||||
else:
|
||||
arguments = dict(TrackingNumber=transaction_id)
|
||||
|
||||
return self.call("CancelIndicium", **arguments)
|
||||
|
||||
|
||||
class XDecimal(XBuiltin):
|
||||
"""Represents an XSD decimal type.
|
||||
"""
|
||||
|
||||
def translate(self, value, topython=True):
|
||||
"""Translate between string and decimal values.
|
||||
|
||||
:param value: The value to translate.
|
||||
:param topython: Default `True`. Determine whether to translate the
|
||||
value for python.
|
||||
"""
|
||||
if topython:
|
||||
if isinstance(value, str) and len(value):
|
||||
ret_val = Decimal(value)
|
||||
else:
|
||||
ret_val = None
|
||||
else:
|
||||
if isinstance(value, (int, float, Decimal)):
|
||||
ret_val = str(value)
|
||||
else:
|
||||
ret_val = value
|
||||
|
||||
return ret_val
|
||||
149
delivery_stamps/models/api/tests.py
Executable file
149
delivery_stamps/models/api/tests.py
Executable file
@@ -0,0 +1,149 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
stamps.tests
|
||||
~~~~~~~~~~~~
|
||||
|
||||
Stamps.com API tests.
|
||||
|
||||
:copyright: 2014 by Jonathan Zempel.
|
||||
:license: BSD, see LICENSE for more details.
|
||||
"""
|
||||
|
||||
from .config import StampsConfiguration
|
||||
from .services import StampsService
|
||||
from datetime import date, datetime
|
||||
from time import sleep
|
||||
from unittest import TestCase
|
||||
import logging
|
||||
import os
|
||||
|
||||
|
||||
logging.basicConfig()
|
||||
logging.getLogger("suds.client").setLevel(logging.DEBUG)
|
||||
file_path = os.path.abspath(__file__)
|
||||
directory_path = os.path.dirname(file_path)
|
||||
file_name = os.path.join(directory_path, "tests.cfg")
|
||||
CONFIGURATION = StampsConfiguration(wsdl="testing", file_name=file_name)
|
||||
|
||||
|
||||
def get_rate(service):
|
||||
"""Get a test rate.
|
||||
|
||||
:param service: Instance of the stamps service.
|
||||
"""
|
||||
ret_val = service.create_shipping()
|
||||
ret_val.ShipDate = date.today().isoformat()
|
||||
ret_val.FromZIPCode = "94107"
|
||||
ret_val.ToZIPCode = "20500"
|
||||
ret_val.PackageType = "Package"
|
||||
rate = service.get_rates(ret_val)[0]
|
||||
ret_val.Amount = rate.Amount
|
||||
ret_val.ServiceType = rate.ServiceType
|
||||
ret_val.DeliverDays = rate.DeliverDays
|
||||
ret_val.DimWeighting = rate.DimWeighting
|
||||
ret_val.Zone = rate.Zone
|
||||
ret_val.RateCategory = rate.RateCategory
|
||||
ret_val.ToState = rate.ToState
|
||||
add_on = service.create_add_on()
|
||||
add_on.AddOnType = "US-A-DC"
|
||||
ret_val.AddOns.AddOnV7.append(add_on)
|
||||
|
||||
return ret_val
|
||||
|
||||
|
||||
def get_from_address(service):
|
||||
"""Get a test 'from' address.
|
||||
|
||||
:param service: Instance of the stamps service.
|
||||
"""
|
||||
address = service.create_address()
|
||||
address.FullName = "Pickwick & Weller"
|
||||
address.Address1 = "300 Brannan St."
|
||||
address.Address2 = "Suite 405"
|
||||
address.City = "San Francisco"
|
||||
address.State = "CA"
|
||||
|
||||
return service.get_address(address).Address
|
||||
|
||||
|
||||
def get_to_address(service):
|
||||
"""Get a test 'to' address.
|
||||
|
||||
:param service: Instance of the stamps service.
|
||||
"""
|
||||
address = service.create_address()
|
||||
address.FullName = "POTUS"
|
||||
address.Address1 = "1600 Pennsylvania Avenue NW"
|
||||
address.City = "Washington"
|
||||
address.State = "DC"
|
||||
|
||||
return service.get_address(address).Address
|
||||
|
||||
|
||||
class StampsTestCase(TestCase):
|
||||
|
||||
initialized = False
|
||||
|
||||
def setUp(self):
|
||||
if not StampsTestCase.initialized:
|
||||
self.service = StampsService(CONFIGURATION)
|
||||
StampsTestCase.initalized = True
|
||||
|
||||
def _test_0(self):
|
||||
"""Test account registration.
|
||||
"""
|
||||
registration = self.service.create_registration()
|
||||
type = self.service.create("CodewordType")
|
||||
registration.Codeword1Type = type.Last4SocialSecurityNumber
|
||||
registration.Codeword1 = 1234
|
||||
registration.Codeword2Type = type.Last4DriversLicense
|
||||
registration.Codeword2 = 1234
|
||||
registration.PhysicalAddress = get_from_address(self.service)
|
||||
registration.MachineInfo.IPAddress = "127.0.0.1"
|
||||
registration.Email = "sws-support@stamps.com"
|
||||
type = self.service.create("AccountType")
|
||||
registration.AccountType = type.OfficeBasedBusiness
|
||||
result = self.service.register_account(registration)
|
||||
print result
|
||||
|
||||
def _test_1(self):
|
||||
"""Test postage purchase.
|
||||
"""
|
||||
transaction_id = datetime.now().isoformat()
|
||||
result = self.service.add_postage(10, transaction_id=transaction_id)
|
||||
transaction_id = result.TransactionID
|
||||
status = self.service.create_purchase_status()
|
||||
seconds = 4
|
||||
|
||||
while result.PurchaseStatus in (status.Pending, status.Processing):
|
||||
seconds = 32 if seconds * 2 >= 32 else seconds * 2
|
||||
print "Waiting {0:d} seconds to get status...".format(seconds)
|
||||
sleep(seconds)
|
||||
result = self.service.get_postage_status(transaction_id)
|
||||
|
||||
print result
|
||||
|
||||
def test_2(self):
|
||||
"""Test label generation.
|
||||
"""
|
||||
self.service = StampsService(CONFIGURATION)
|
||||
rate = get_rate(self.service)
|
||||
from_address = get_from_address(self.service)
|
||||
to_address = get_to_address(self.service)
|
||||
transaction_id = datetime.now().isoformat()
|
||||
label = self.service.get_label(from_address, to_address, rate,
|
||||
transaction_id=transaction_id)
|
||||
self.service.get_tracking(label.StampsTxID)
|
||||
self.service.get_tracking(label.TrackingNumber)
|
||||
self.service.remove_label(label.StampsTxID)
|
||||
print label
|
||||
|
||||
def test_3(self):
|
||||
"""Test authentication retry.
|
||||
"""
|
||||
self.service.get_account()
|
||||
authenticator = self.service.plugin.authenticator
|
||||
self.service.get_account()
|
||||
self.service.plugin.authenticator = authenticator
|
||||
result = self.service.get_account()
|
||||
print result
|
||||
3381
delivery_stamps/models/api/wsdls/stamps_v49.test.wsdl
Executable file
3381
delivery_stamps/models/api/wsdls/stamps_v49.test.wsdl
Executable file
File diff suppressed because it is too large
Load Diff
3381
delivery_stamps/models/api/wsdls/stamps_v49.wsdl
Executable file
3381
delivery_stamps/models/api/wsdls/stamps_v49.wsdl
Executable file
File diff suppressed because it is too large
Load Diff
339
delivery_stamps/models/delivery_stamps.py
Normal file
339
delivery_stamps/models/delivery_stamps.py
Normal file
@@ -0,0 +1,339 @@
|
||||
from datetime import date
|
||||
from logging import getLogger
|
||||
from urllib.request import urlopen
|
||||
from suds import WebFault
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
from .api.config import StampsConfiguration
|
||||
from .api.services import StampsService
|
||||
|
||||
_logger = getLogger(__name__)
|
||||
|
||||
STAMPS_PACKAGE_TYPES = [
|
||||
'Unknown',
|
||||
'Postcard',
|
||||
'Letter',
|
||||
'Large Envelope or Flat',
|
||||
'Thick Envelope',
|
||||
'Package',
|
||||
'Flat Rate Box',
|
||||
'Small Flat Rate Box',
|
||||
'Large Flat Rate Box',
|
||||
'Flat Rate Envelope',
|
||||
'Flat Rate Padded Envelope',
|
||||
'Large Package',
|
||||
'Oversized Package',
|
||||
'Regional Rate Box A',
|
||||
'Regional Rate Box B',
|
||||
'Legal Flat Rate Envelope',
|
||||
'Regional Rate Box C',
|
||||
]
|
||||
|
||||
|
||||
class ProductPackaging(models.Model):
|
||||
_inherit = 'product.packaging'
|
||||
|
||||
package_carrier_type = fields.Selection(selection_add=[('stamps', 'Stamps.com')])
|
||||
stamps_cubic_pricing = fields.Boolean(string="Stamps.com Use Cubic Pricing")
|
||||
|
||||
|
||||
class ProviderStamps(models.Model):
|
||||
_inherit = 'delivery.carrier'
|
||||
|
||||
delivery_type = fields.Selection(selection_add=[('stamps', 'Stamps.com (USPS)')])
|
||||
|
||||
stamps_integration_id = fields.Char(string='Stamps.com Integration ID', groups='base.group_system')
|
||||
stamps_username = fields.Char(string='Stamps.com Username', groups='base.group_system')
|
||||
stamps_password = fields.Char(string='Stamps.com Password', groups='base.group_system')
|
||||
|
||||
stamps_service_type = fields.Selection([('US-FC', 'First-Class'),
|
||||
('US-PM', 'Priority'),
|
||||
],
|
||||
required=True, string="Service Type", default="US-PM")
|
||||
stamps_default_packaging_id = fields.Many2one('product.packaging', string='Default Package Type')
|
||||
|
||||
stamps_image_type = fields.Selection([('Auto', 'Auto'),
|
||||
('Png', 'PNG'),
|
||||
('Gif', 'GIF'),
|
||||
('Pdf', 'PDF'),
|
||||
('Epl', 'EPL'),
|
||||
('Jpg', 'JPG'),
|
||||
('PrintOncePdf', 'Print Once PDF'),
|
||||
('EncryptedPngUrl', 'Encrypted PNG URL'),
|
||||
('Zpl', 'ZPL'),
|
||||
('AZpl', 'AZPL'),
|
||||
('BZpl', 'BZPL'),
|
||||
],
|
||||
required=True, string="Image Type", default="Pdf")
|
||||
|
||||
def _stamps_package_type(self, package=None):
|
||||
if not package:
|
||||
return self.stamps_default_packaging_id.shipper_package_code
|
||||
return package.packaging_id.shipper_package_code if package.packaging_id.shipper_package_code in STAMPS_PACKAGE_TYPES else 'Package'
|
||||
|
||||
def _stamps_package_is_cubic_pricing(self, package=None):
|
||||
if not package:
|
||||
return self.stamps_default_packaging_id.stamps_cubic_pricing
|
||||
return package.packaging_id.stamps_cubic_pricing
|
||||
|
||||
def _stamps_package_dimensions(self, package=None):
|
||||
if not package:
|
||||
package_type = self.stamps_default_packaging_id
|
||||
else:
|
||||
package_type = package.packaging_id
|
||||
return package_type.length, package_type.width, package_type.height
|
||||
|
||||
def _get_stamps_service(self):
|
||||
sudoself = self.sudo()
|
||||
config = StampsConfiguration(integration_id=sudoself.stamps_integration_id,
|
||||
username=sudoself.stamps_username,
|
||||
password=sudoself.stamps_password)
|
||||
return StampsService(configuration=config)
|
||||
|
||||
def _stamps_convert_weight(self, weight):
|
||||
""" weight always expressed in KG """
|
||||
if self.stamps_default_packaging_id.max_weight and self.stamps_default_packaging_id.max_weight < weight:
|
||||
raise ValidationError('Stamps cannot ship for weight: ' + str(weight) + 'kgs.')
|
||||
|
||||
weight_in_pounds = weight * 2.20462
|
||||
return weight_in_pounds
|
||||
|
||||
def _get_stamps_shipping_for_order(self, service, order, date_planned):
|
||||
weight = sum([(line.product_id.weight * line.product_qty) for line in order.order_line]) or 0.0
|
||||
weight = self._stamps_convert_weight(weight)
|
||||
|
||||
if not all((order.warehouse_id.partner_id.zip, order.partner_shipping_id.zip)):
|
||||
raise ValidationError('Stamps needs ZIP. From: ' + str(order.warehouse_id.partner_id.zip) + ' To: ' + str(order.partner_shipping_id.zip))
|
||||
|
||||
ret_val = service.create_shipping()
|
||||
ret_val.ShipDate = date_planned.split()[0] if date_planned else date.today().isoformat()
|
||||
ret_val.FromZIPCode = order.warehouse_id.partner_id.zip
|
||||
ret_val.ToZIPCode = order.partner_shipping_id.zip
|
||||
ret_val.PackageType = self._stamps_package_type()
|
||||
ret_val.ServiceType = self.stamps_service_type
|
||||
ret_val.WeightLb = weight
|
||||
return ret_val
|
||||
|
||||
def _get_order_for_picking(self, picking):
|
||||
if picking.sale_id:
|
||||
return picking.sale_id
|
||||
return None
|
||||
|
||||
def _get_company_for_order(self, order):
|
||||
company = order.company_id
|
||||
if order.team_id and order.team_id.subcompany_id:
|
||||
company = order.team_id.subcompany_id.company_id
|
||||
elif order.analytic_account_id and order.analytic_account_id.subcompany_id:
|
||||
company = order.analytic_account_id.subcompany_id.company_id
|
||||
return company
|
||||
|
||||
def _get_company_for_picking(self, picking):
|
||||
order = self._get_order_for_picking(picking)
|
||||
if order:
|
||||
return self._get_company_for_order(order)
|
||||
return picking.company_id
|
||||
|
||||
def _stamps_get_addresses_for_picking(self, picking):
|
||||
company = self._get_company_for_picking(picking)
|
||||
from_ = picking.picking_type_id.warehouse_id.partner_id
|
||||
to = picking.partner_id
|
||||
return company, from_, to
|
||||
|
||||
def _stamps_get_shippings_for_picking(self, service, picking):
|
||||
ret = []
|
||||
company, from_partner, to_partner = self._stamps_get_addresses_for_picking(picking)
|
||||
if not all((from_partner.zip, to_partner.zip)):
|
||||
raise ValidationError('Stamps needs ZIP. From: ' + str(from_partner.zip) + ' To: ' + str(to_partner.zip))
|
||||
|
||||
for package in picking.package_ids:
|
||||
weight = self._stamps_convert_weight(package.shipping_weight)
|
||||
l, w, h = self._stamps_package_dimensions(package=package)
|
||||
|
||||
ret_val = service.create_shipping()
|
||||
ret_val.ShipDate = date.today().isoformat()
|
||||
ret_val.FromZIPCode = from_partner.zip
|
||||
ret_val.ToZIPCode = to_partner.zip
|
||||
ret_val.PackageType = self._stamps_package_type(package=package)
|
||||
ret_val.CubicPricing = self._stamps_package_is_cubic_pricing(package=package)
|
||||
ret_val.Length = l
|
||||
ret_val.Width = w
|
||||
ret_val.Height = h
|
||||
ret_val.ServiceType = self.stamps_service_type
|
||||
ret_val.WeightLb = weight
|
||||
ret.append((package.name + ret_val.ShipDate + str(ret_val.WeightLb), ret_val))
|
||||
if not ret:
|
||||
weight = self._stamps_convert_weight(picking.shipping_weight)
|
||||
l, w, h = self._stamps_package_dimensions()
|
||||
|
||||
ret_val = service.create_shipping()
|
||||
ret_val.ShipDate = date.today().isoformat()
|
||||
ret_val.FromZIPCode = from_partner.zip
|
||||
ret_val.ToZIPCode = to_partner.zip
|
||||
ret_val.PackageType = self._stamps_package_type()
|
||||
ret_val.CubicPricing = self._stamps_package_is_cubic_pricing()
|
||||
ret_val.Length = l
|
||||
ret_val.Width = w
|
||||
ret_val.Height = h
|
||||
ret_val.ServiceType = self.stamps_service_type
|
||||
ret_val.WeightLb = weight
|
||||
ret.append((picking.name + ret_val.ShipDate + str(ret_val.WeightLb), ret_val))
|
||||
|
||||
return ret
|
||||
|
||||
def stamps_get_shipping_price_from_so(self, orders):
|
||||
res = self.stamps_get_shipping_price_for_plan(orders, date.today().isoformat())
|
||||
return map(lambda r: r[0] if r else 0.0, res)
|
||||
|
||||
def stamps_get_shipping_price_for_plan(self, orders, date_planned):
|
||||
res = []
|
||||
service = self._get_stamps_service()
|
||||
|
||||
for order in orders:
|
||||
shipping = self._get_stamps_shipping_for_order(service, order, date_planned)
|
||||
rates = service.get_rates(shipping)
|
||||
if rates and len(rates) >= 1:
|
||||
rate = rates[0]
|
||||
price = float(rate.Amount)
|
||||
|
||||
if order.currency_id.name != 'USD':
|
||||
quote_currency = self.env['res.currency'].search([('name', '=', 'USD')], limit=1)
|
||||
price = quote_currency.compute(rate.Amount, order.currency_id)
|
||||
|
||||
delivery_days = rate.DeliverDays
|
||||
if delivery_days.find('-') >= 0:
|
||||
delivery_days = delivery_days.split('-')
|
||||
transit_days = int(delivery_days[-1])
|
||||
else:
|
||||
transit_days = int(delivery_days)
|
||||
date_delivered = None
|
||||
if date_planned and transit_days > 0:
|
||||
date_delivered = self.calculate_date_delivered(date_planned, transit_days)
|
||||
|
||||
res = res + [(price, transit_days, date_delivered)]
|
||||
continue
|
||||
res = res + [(0.0, 0, None)]
|
||||
return res
|
||||
|
||||
def stamps_rate_shipment(self, order):
|
||||
self.ensure_one()
|
||||
result = {
|
||||
'success': False,
|
||||
'price': 0.0,
|
||||
'error_message': 'Error Retrieving Response from Stamps.com',
|
||||
'warning_message': False
|
||||
}
|
||||
date_planned = None
|
||||
if self.env.context.get('date_planned'):
|
||||
date_planned = self.env.context.get('date_planned')
|
||||
rate = self.stamps_get_shipping_price_for_plan(order, date_planned)
|
||||
if rate:
|
||||
price, transit_time, date_delivered = rate[0]
|
||||
result.update({
|
||||
'success': True,
|
||||
'price': price,
|
||||
'error_message': False,
|
||||
'transit_time': transit_time,
|
||||
'date_delivered': date_delivered,
|
||||
})
|
||||
return result
|
||||
return result
|
||||
|
||||
def stamps_send_shipping(self, pickings):
|
||||
res = []
|
||||
service = self._get_stamps_service()
|
||||
|
||||
for picking in pickings:
|
||||
package_labels = []
|
||||
|
||||
shippings = self._stamps_get_shippings_for_picking(service, picking)
|
||||
company, from_partner, to_partner = self._stamps_get_addresses_for_picking(picking)
|
||||
|
||||
from_address = service.create_address()
|
||||
from_address.FullName = company.partner_id.name
|
||||
from_address.Address1 = from_partner.street
|
||||
if from_partner.street2:
|
||||
from_address.Address2 = from_partner.street2
|
||||
from_address.City = from_partner.city
|
||||
from_address.State = from_partner.state_id.code
|
||||
from_address = service.get_address(from_address).Address
|
||||
|
||||
to_address = service.create_address()
|
||||
to_address.FullName = to_partner.name
|
||||
to_address.Address1 = to_partner.street
|
||||
if to_partner.street2:
|
||||
to_address.Address2 = to_partner.street2
|
||||
to_address.City = to_partner.city
|
||||
to_address.State = to_partner.state_id.code
|
||||
to_address = service.get_address(to_address).Address
|
||||
|
||||
try:
|
||||
for txn_id, shipping in shippings:
|
||||
rates = service.get_rates(shipping)
|
||||
if rates and len(rates) >= 1:
|
||||
rate = rates[0]
|
||||
shipping.Amount = rate.Amount
|
||||
shipping.ServiceType = rate.ServiceType
|
||||
shipping.DeliverDays = rate.DeliverDays
|
||||
shipping.DimWeighting = rate.DimWeighting
|
||||
shipping.Zone = rate.Zone
|
||||
shipping.RateCategory = rate.RateCategory
|
||||
shipping.ToState = rate.ToState
|
||||
add_on = service.create_add_on()
|
||||
add_on.AddOnType = 'US-A-DC'
|
||||
add_on2 = service.create_add_on()
|
||||
add_on2.AddOnType = 'SC-A-HP'
|
||||
shipping.AddOns.AddOnV7 = [add_on, add_on2]
|
||||
label = service.get_label(from_address, to_address, shipping,
|
||||
transaction_id=txn_id, image_type=self.stamps_image_type)
|
||||
package_labels.append((txn_id, label))
|
||||
# self.service.get_tracking(label.StampsTxID)
|
||||
# self.service.get_tracking(label.TrackingNumber)
|
||||
# self.service.remove_label(label.StampsTxID)
|
||||
# print label
|
||||
except WebFault as e:
|
||||
_logger.warn(e)
|
||||
if package_labels:
|
||||
for name, label in package_labels:
|
||||
body = 'Cancelling due to error: ' + str(label.TrackingNumber)
|
||||
try:
|
||||
service.remove_label(label.TrackingNumber)
|
||||
except WebFault as e:
|
||||
raise ValidationError(e)
|
||||
else:
|
||||
picking.message_post(body=body)
|
||||
raise ValidationError('Error on full shipment. Attempted to cancel any previously shipped.')
|
||||
raise ValidationError('Error on shipment. ' + str(e))
|
||||
else:
|
||||
carrier_price = 0.0
|
||||
tracking_numbers = []
|
||||
for name, label in package_labels:
|
||||
body = 'Shipment created into Stamps.com <br/> <b>Tracking Number : <br/>' + label.TrackingNumber + '</b>'
|
||||
tracking_numbers.append(label.TrackingNumber)
|
||||
carrier_price += float(label.Rate.Amount)
|
||||
url = label.URL
|
||||
|
||||
response = urlopen(url)
|
||||
attachment = response.read()
|
||||
picking.message_post(body=body, attachments=[('LabelStamps-%s.%s' % (label.TrackingNumber, self.stamps_image_type), attachment)])
|
||||
shipping_data = {'exact_price': carrier_price, 'tracking_number': ','.join(tracking_numbers)}
|
||||
res = res + [shipping_data]
|
||||
return res
|
||||
|
||||
def stamps_get_tracking_link(self, pickings):
|
||||
res = []
|
||||
for picking in pickings:
|
||||
ref = picking.carrier_tracking_ref
|
||||
res = res + ['https://tools.usps.com/go/TrackConfirmAction_input?qtc_tLabels1=%s' % ref]
|
||||
return res
|
||||
|
||||
def stamps_cancel_shipment(self, picking):
|
||||
service = self._get_stamps_service()
|
||||
try:
|
||||
service.remove_label(picking.carrier_tracking_ref)
|
||||
picking.message_post(body=_(u'Shipment N° %s has been cancelled' % picking.carrier_tracking_ref))
|
||||
picking.write({'carrier_tracking_ref': '',
|
||||
'carrier_price': 0.0})
|
||||
except WebFault as e:
|
||||
raise ValidationError(e)
|
||||
39
delivery_stamps/views/delivery_stamps_view.xml
Normal file
39
delivery_stamps/views/delivery_stamps_view.xml
Normal file
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_delivery_carrier_form_with_provider_stamps" model="ir.ui.view">
|
||||
<field name="name">delivery.carrier.form.provider.stamps</field>
|
||||
<field name="model">delivery.carrier</field>
|
||||
<field name="inherit_id" ref="delivery.view_delivery_carrier_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//page[@name='destination']" position='before'>
|
||||
<page string="Stamps.com Configuration" attrs="{'invisible': [('delivery_type', '!=', 'stamps')]}">
|
||||
<group>
|
||||
<group>
|
||||
<field name="stamps_integration_id" attrs="{'required': [('delivery_type', '=', 'stamps')]}" />
|
||||
<field name="stamps_username" attrs="{'required': [('delivery_type', '=', 'stamps')]}" />
|
||||
<field name="stamps_password" attrs="{'required': [('delivery_type', '=', 'stamps')]}" password="True"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="stamps_service_type" attrs="{'required': [('delivery_type', '==', 'stamps')]}"/>
|
||||
<field name="stamps_default_packaging_id" attrs="{'required': [('delivery_type', '==', 'stamps')]}"/>
|
||||
<field name="stamps_image_type" attrs="{'required': [('delivery_type', '==', 'stamps')]}"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="product_packaging_delivery_form" model="ir.ui.view">
|
||||
<field name="name">stamps.product.packaging.form.delivery</field>
|
||||
<field name="model">product.packaging</field>
|
||||
<field name="inherit_id" ref="delivery.product_packaging_delivery_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='max_weight']" position='after'>
|
||||
<field name="stamps_cubic_pricing"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
2
external/hibou-oca/account-analytic
vendored
2
external/hibou-oca/account-analytic
vendored
Submodule external/hibou-oca/account-analytic updated: 496db65e9d...6c98fa67a4
1
external/hibou-oca/purchase-workflow
vendored
Submodule
1
external/hibou-oca/purchase-workflow
vendored
Submodule
Submodule external/hibou-oca/purchase-workflow added at 5973bb878b
1
external/hibou-oca/stock-logistics-warehouse
vendored
Submodule
1
external/hibou-oca/stock-logistics-warehouse
vendored
Submodule
Submodule external/hibou-oca/stock-logistics-warehouse added at eb04e0ba5f
1
external/hibou-oca/stock-logistics-workflow
vendored
Submodule
1
external/hibou-oca/stock-logistics-workflow
vendored
Submodule
Submodule external/hibou-oca/stock-logistics-workflow added at cc0b36e76c
1
hr_expense_recruitment/__init__.py
Normal file
1
hr_expense_recruitment/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
21
hr_expense_recruitment/__manifest__.py
Normal file
21
hr_expense_recruitment/__manifest__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
'name': 'HR Expense Recruitment',
|
||||
'version': '11.0.1.0.0',
|
||||
'author': 'Hibou Corp. <hello@hibou.io>',
|
||||
'category': 'Human Resources',
|
||||
'summary': 'Assign Recruitment to expenses for reporting.',
|
||||
'description': """
|
||||
Assign Recruitment to expenses for reporting.
|
||||
""",
|
||||
'website': 'https://hibou.io/',
|
||||
'depends': [
|
||||
'hr_expense',
|
||||
'hr_recruitment',
|
||||
],
|
||||
'data': [
|
||||
'views/hr_expense_views.xml',
|
||||
'views/hr_job_views.xml'
|
||||
],
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
}
|
||||
2
hr_expense_recruitment/models/__init__.py
Normal file
2
hr_expense_recruitment/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import hr_expense_job
|
||||
from . import hr_job
|
||||
7
hr_expense_recruitment/models/hr_expense_job.py
Normal file
7
hr_expense_recruitment/models/hr_expense_job.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class HRExpenseJob(models.Model):
|
||||
_inherit = 'hr.expense'
|
||||
|
||||
job_id = fields.Many2one('hr.job', string='Job')
|
||||
18
hr_expense_recruitment/models/hr_job.py
Normal file
18
hr_expense_recruitment/models/hr_job.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class HRJob(models.Model):
|
||||
_inherit = 'hr.job'
|
||||
|
||||
company_currency = fields.Many2one('res.currency', string='Currency',
|
||||
related='company_id.currency_id', readonly=True)
|
||||
expense_total_amount = fields.Float(string='Expenses Total',
|
||||
compute='_compute_expense_total_amount',
|
||||
compute_sudo=True)
|
||||
expense_ids = fields.One2many('hr.expense', 'job_id', string='Expenses')
|
||||
|
||||
@api.multi
|
||||
@api.depends('expense_ids.total_amount')
|
||||
def _compute_expense_total_amount(self):
|
||||
for job in self:
|
||||
job.expense_total_amount = sum(job.expense_ids.mapped('total_amount'))
|
||||
1
hr_expense_recruitment/tests/__init__.py
Normal file
1
hr_expense_recruitment/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_expenses
|
||||
16
hr_expense_recruitment/tests/test_expenses.py
Normal file
16
hr_expense_recruitment/tests/test_expenses.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from odoo.tests import common
|
||||
|
||||
|
||||
class TestJobExpense(common.TransactionCase):
|
||||
def test_fields(self):
|
||||
job = self.env['hr.job'].create({'name': 'Test Job'})
|
||||
expense = self.env['hr.expense'].create({
|
||||
'name': 'Test Expense',
|
||||
'product_id': self.env['product.product'].search([('can_be_expensed', '=', True)], limit=1).id,
|
||||
'unit_amount': 34.0,
|
||||
})
|
||||
self.assertFalse(job.expense_ids)
|
||||
self.assertEqual(job.expense_total_amount, 0.0)
|
||||
expense.job_id = job
|
||||
self.assertTrue(job.expense_ids)
|
||||
self.assertEqual(job.expense_total_amount, 34.0)
|
||||
24
hr_expense_recruitment/views/hr_expense_views.xml
Normal file
24
hr_expense_recruitment/views/hr_expense_views.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="view_hr_expense_form_inherit" model="ir.ui.view">
|
||||
<field name="name">hr.expense.form.inherit</field>
|
||||
<field name="model">hr.expense</field>
|
||||
<field name="inherit_id" ref="hr_expense.hr_expense_form_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='employee_id']" position="after">
|
||||
<field name="job_id"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_hr_expense_filter_inherit" model="ir.ui.view">
|
||||
<field name="name">hr.expense.filter.inherit</field>
|
||||
<field name="model">hr.expense</field>
|
||||
<field name="inherit_id" ref="hr_expense.view_hr_expense_filter"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='product_id']" position="after">
|
||||
<field name="job_id"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
19
hr_expense_recruitment/views/hr_job_views.xml
Normal file
19
hr_expense_recruitment/views/hr_job_views.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="view_hr_job_form_inherit" model="ir.ui.view">
|
||||
<field name="name">hr.job.form.inherit</field>
|
||||
<field name="model">hr.job</field>
|
||||
<field name="inherit_id" ref="hr.view_hr_job_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//sheet/div[@name='button_box']" position="inside">
|
||||
<field name="company_currency" invisible="1"/>
|
||||
<button name="%(hr_expense.hr_expense_actions_all)d" class="oe_stat_button" icon="fa-money" type="action" context="{'search_default_job_id': active_id}">
|
||||
<div class="o_stat_info">
|
||||
<field name="expense_total_amount" widget="monetary" nolabel="1" options="{'currency_field': 'company_currency'}"/>
|
||||
<span class="o_stat_text"> Expenses</span>
|
||||
</div>
|
||||
</button>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -39,10 +39,11 @@ class HrPayrollRegisterPaymentWizard(models.TransientModel):
|
||||
context = dict(self._context or {})
|
||||
active_ids = context.get('active_ids', [])
|
||||
payslip = self.env['hr.payslip'].browse(active_ids)
|
||||
amount = 0.0
|
||||
for line in payslip.move_id.line_ids:
|
||||
if line.account_id.internal_type == 'payable' and line.partner_id.id == payslip.employee_id.address_home_id.id:
|
||||
amount += abs(line.balance)
|
||||
amount = -sum(payslip.move_id.line_ids.filtered(lambda l: (
|
||||
l.account_id.internal_type == 'payable'
|
||||
and l.partner_id.id == payslip.employee_id.address_home_id.id
|
||||
and not l.reconciled)
|
||||
).mapped('balance'))
|
||||
return amount
|
||||
|
||||
@api.model
|
||||
@@ -142,7 +143,7 @@ class HrPayrollRegisterPaymentWizard(models.TransientModel):
|
||||
if line.account_id.internal_type == 'payable':
|
||||
account_move_lines_to_reconcile |= line
|
||||
for line in payslip.move_id.line_ids:
|
||||
if line.account_id.internal_type == 'payable' and line.partner_id.id == self.partner_id.id:
|
||||
if line.account_id.internal_type == 'payable' and line.partner_id.id == self.partner_id.id and not line.reconciled:
|
||||
account_move_lines_to_reconcile |= line
|
||||
|
||||
account_move_lines_to_reconcile.reconcile()
|
||||
|
||||
4
hr_payroll_timesheet/__init__.py
Executable file → Normal file
4
hr_payroll_timesheet/__init__.py
Executable file → Normal file
@@ -1,3 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import hr_payslip
|
||||
from . import hr_contract
|
||||
from . import models
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
{
|
||||
'name': 'Timesheets on Payslips',
|
||||
'description': 'Get Timesheet and Attendence numbers onto Employee Payslips.',
|
||||
'version': '11.0.0.0.0',
|
||||
'description': 'Get Timesheet hours onto Employee Payslips.',
|
||||
'version': '11.0.1.0.0',
|
||||
'website': 'https://hibou.io/',
|
||||
'author': 'Hibou Corp. <hello@hibou.io>',
|
||||
'license': 'AGPL-3',
|
||||
'category': 'Human Resources',
|
||||
'data': [
|
||||
'hr_contract_view.xml',
|
||||
'views/hr_contract_view.xml',
|
||||
],
|
||||
'depends': [
|
||||
'hr_payroll',
|
||||
'hr_timesheet_attendance',
|
||||
'hr_timesheet',
|
||||
],
|
||||
}
|
||||
|
||||
2
hr_payroll_timesheet/models/__init__.py
Normal file
2
hr_payroll_timesheet/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import hr_contract
|
||||
from . import hr_payslip
|
||||
7
hr_payroll_timesheet/models/hr_contract.py
Normal file
7
hr_payroll_timesheet/models/hr_contract.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class HrContract(models.Model):
|
||||
_inherit = 'hr.contract'
|
||||
|
||||
paid_hourly_timesheet = fields.Boolean(string="Paid Hourly Timesheet", default=False)
|
||||
99
hr_payroll_timesheet/models/hr_payslip.py
Normal file
99
hr_payroll_timesheet/models/hr_payslip.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
from odoo import api, models
|
||||
from odoo.tools import DEFAULT_SERVER_DATE_FORMAT
|
||||
|
||||
|
||||
class HrPayslip(models.Model):
|
||||
_inherit = 'hr.payslip'
|
||||
|
||||
@api.model
|
||||
def get_worked_day_lines(self, contracts, date_from, date_to):
|
||||
work = []
|
||||
for contract in contracts.filtered(lambda c: c.paid_hourly_timesheet):
|
||||
# Only run on 'paid hourly timesheet' contracts.
|
||||
res = self._get_worked_day_lines_hourly_timesheet(contract, date_from, date_to)
|
||||
if res:
|
||||
work.append(res)
|
||||
|
||||
res = super(HrPayslip, self).get_worked_day_lines(contracts.filtered(lambda c: not c.paid_hourly_timesheet), date_from, date_to)
|
||||
res.extend(work)
|
||||
return res
|
||||
|
||||
def _get_worked_day_lines_hourly_timesheet(self, contract, date_from, date_to):
|
||||
"""
|
||||
This would be a common hook to extend or break out more functionality, like pay rate based on project.
|
||||
Note that you will likely need to aggregate similarly in hour_break_down() and hour_break_down_week()
|
||||
:param contract: `hr.contract`
|
||||
:param date_from: str
|
||||
:param date_to: str
|
||||
:return: dict of values for `hr.payslip.worked_days`
|
||||
"""
|
||||
values = {
|
||||
'name': 'Timesheet',
|
||||
'sequence': 15,
|
||||
'code': 'TS',
|
||||
'number_of_days': 0.0,
|
||||
'number_of_hours': 0.0,
|
||||
'contract_id': contract.id,
|
||||
}
|
||||
|
||||
valid_ts = [
|
||||
# ('is_timesheet', '=', True),
|
||||
# 'is_timesheet' is computed if there is a project_id associated with the entry
|
||||
('project_id', '!=', False),
|
||||
('employee_id', '=', contract.employee_id.id),
|
||||
('date', '>=', date_from),
|
||||
('date', '<=', date_to),
|
||||
]
|
||||
|
||||
days = set()
|
||||
for ts in self.env['account.analytic.line'].search(valid_ts):
|
||||
if ts.unit_amount:
|
||||
ts_date = datetime.strptime(ts.date, DEFAULT_SERVER_DATE_FORMAT)
|
||||
ts_iso = ts_date.isocalendar()
|
||||
if ts_iso not in days:
|
||||
values['number_of_days'] += 1
|
||||
days.add(ts_iso)
|
||||
values['number_of_hours'] += ts.unit_amount
|
||||
|
||||
values['number_of_hours'] = round(values['number_of_hours'], 2)
|
||||
return values
|
||||
|
||||
@api.multi
|
||||
def hour_break_down(self, code):
|
||||
"""
|
||||
:param code: what kind of worked days you need aggregated
|
||||
:return: dict: keys are isocalendar tuples, values are hours.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if code == 'TS':
|
||||
timesheets = self.env['account.analytic.line'].search([
|
||||
# ('is_timesheet', '=', True),
|
||||
# 'is_timesheet' is computed if there is a project_id associated with the entry
|
||||
('project_id', '!=', False),
|
||||
('employee_id', '=', self.employee_id.id),
|
||||
('date', '>=', self.date_from),
|
||||
('date', '<=', self.date_to),
|
||||
])
|
||||
day_values = defaultdict(float)
|
||||
for ts in timesheets:
|
||||
if ts.unit_amount:
|
||||
ts_date = datetime.strptime(ts.date, DEFAULT_SERVER_DATE_FORMAT)
|
||||
ts_iso = ts_date.isocalendar()
|
||||
day_values[ts_iso] += ts.unit_amount
|
||||
return day_values
|
||||
elif hasattr(super(HrPayslip, self), 'hour_break_down'):
|
||||
return super(HrPayslip, self).hour_break_down(code)
|
||||
|
||||
@api.multi
|
||||
def hours_break_down_week(self, code):
|
||||
"""
|
||||
:param code: hat kind of worked days you need aggregated
|
||||
:return: dict: keys are isocalendar weeks, values are hours.
|
||||
"""
|
||||
days = self.hour_break_down(code)
|
||||
weeks = defaultdict(float)
|
||||
for isoday, hours in days.items():
|
||||
weeks[isoday[1]] += hours
|
||||
return weeks
|
||||
1
hr_payroll_timesheet/tests/__init__.py
Executable file
1
hr_payroll_timesheet/tests/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
from . import test_payslip_timesheet
|
||||
87
hr_payroll_timesheet/tests/test_payslip_timesheet.py
Normal file
87
hr_payroll_timesheet/tests/test_payslip_timesheet.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from odoo.tests import common
|
||||
from odoo import fields
|
||||
|
||||
|
||||
class TestPayslipTimesheet(common.TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestPayslipTimesheet, self).setUp()
|
||||
self.employee = self.env['hr.employee'].create({
|
||||
'birthday': '1985-03-14',
|
||||
'country_id': self.ref('base.us'),
|
||||
'department_id': self.ref('hr.dep_rd'),
|
||||
'gender': 'male',
|
||||
'name': 'Jared'
|
||||
})
|
||||
self.contract = self.env['hr.contract'].create({
|
||||
'name': 'test',
|
||||
'employee_id': self.employee.id,
|
||||
'type_id': self.ref('hr_contract.hr_contract_type_emp'),
|
||||
'struct_id': self.ref('hr_payroll.structure_base'),
|
||||
'resource_calendar_id': self.ref('resource.resource_calendar_std'),
|
||||
'wage': 21.50,
|
||||
'date_start': '2018-01-01',
|
||||
'state': 'open',
|
||||
'paid_hourly_timesheet': True,
|
||||
'schedule_pay': 'monthly',
|
||||
})
|
||||
self.project = self.env['project.project'].create({
|
||||
'name': 'Timesheets',
|
||||
})
|
||||
|
||||
def test_payslip_timesheet(self):
|
||||
self.assertTrue(self.contract.paid_hourly_timesheet)
|
||||
from_date = '2018-01-01'
|
||||
to_date = '2018-01-31'
|
||||
|
||||
# Day 1
|
||||
self.env['account.analytic.line'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'project_id': self.project.id,
|
||||
'date': '2018-01-01',
|
||||
'unit_amount': 5.0,
|
||||
})
|
||||
self.env['account.analytic.line'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'project_id': self.project.id,
|
||||
'date': '2018-01-01',
|
||||
'unit_amount': 3.0,
|
||||
})
|
||||
|
||||
# Day 2
|
||||
self.env['account.analytic.line'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'project_id': self.project.id,
|
||||
'date': '2018-01-02',
|
||||
'unit_amount': 1.0,
|
||||
})
|
||||
|
||||
# Make one that should be excluded.
|
||||
self.env['account.analytic.line'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'project_id': self.project.id,
|
||||
'date': '2017-01-01',
|
||||
'unit_amount': 5.0,
|
||||
})
|
||||
|
||||
# Create slip like a batch run.
|
||||
slip_data = self.env['hr.payslip'].onchange_employee_id(from_date, to_date, self.employee.id, contract_id=False)
|
||||
res = {
|
||||
'employee_id': self.employee.id,
|
||||
'name': slip_data['value'].get('name'),
|
||||
'struct_id': slip_data['value'].get('struct_id'),
|
||||
'contract_id': slip_data['value'].get('contract_id'),
|
||||
'input_line_ids': [(0, 0, x) for x in slip_data['value'].get('input_line_ids')],
|
||||
'worked_days_line_ids': [(0, 0, x) for x in slip_data['value'].get('worked_days_line_ids')],
|
||||
'date_from': from_date,
|
||||
'date_to': to_date,
|
||||
'company_id': self.employee.company_id.id,
|
||||
}
|
||||
payslip = self.env['hr.payslip'].create(res)
|
||||
payslip.compute_sheet()
|
||||
self.assertTrue(payslip.worked_days_line_ids)
|
||||
|
||||
timesheet_line = payslip.worked_days_line_ids.filtered(lambda l: l.code == 'TS')
|
||||
self.assertTrue(timesheet_line)
|
||||
self.assertEqual(timesheet_line.number_of_days, 2.0)
|
||||
self.assertEqual(timesheet_line.number_of_hours, 9.0)
|
||||
19
hr_payroll_timesheet/views/hr_contract_view.xml
Executable file
19
hr_payroll_timesheet/views/hr_contract_view.xml
Executable file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="hr_contract_form_inherit" model="ir.ui.view">
|
||||
<field name="name">hr.contract.form.inherit</field>
|
||||
<field name="model">hr.contract</field>
|
||||
<field name="inherit_id" ref="hr_contract.hr_contract_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<data>
|
||||
<xpath expr="//field[@name='advantages']" position="after">
|
||||
<field name="paid_hourly_timesheet"/>
|
||||
</xpath>
|
||||
<xpath expr="//div[@name='wage']/span" position="replace">
|
||||
<span attrs="{'invisible': [('paid_hourly_timesheet', '=', True)]}">/ pay period</span>
|
||||
<span attrs="{'invisible': [('paid_hourly_timesheet', '=', False)]}">/ hour</span>
|
||||
</xpath>
|
||||
</data>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
1
hr_payroll_timesheet_holidays/__init__.py
Executable file
1
hr_payroll_timesheet_holidays/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
from . import hr_payslip
|
||||
15
hr_payroll_timesheet_holidays/__manifest__.py
Executable file
15
hr_payroll_timesheet_holidays/__manifest__.py
Executable file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
'name': 'Payroll Timesheet Holidays',
|
||||
'author': 'Hibou Corp. <hello@hibou.io>',
|
||||
'version': '11.0.1.0.0',
|
||||
'category': 'Human Resources',
|
||||
'sequence': 95,
|
||||
'summary': 'Holiday Pay',
|
||||
'description': """
|
||||
Simplifies getting approved Holiday Leaves onto an employee Payslip.
|
||||
""",
|
||||
'website': 'https://hibou.io/',
|
||||
'depends': ['hr_payroll_timesheet', 'hr_holidays'],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
}
|
||||
52
hr_payroll_timesheet_holidays/hr_payslip.py
Executable file
52
hr_payroll_timesheet_holidays/hr_payslip.py
Executable file
@@ -0,0 +1,52 @@
|
||||
from odoo import models, api
|
||||
from odoo.addons.hr_holidays.models.hr_holidays import HOURS_PER_DAY
|
||||
|
||||
|
||||
class HrPayslip(models.Model):
|
||||
_inherit = 'hr.payslip'
|
||||
|
||||
@api.model
|
||||
def get_worked_day_lines(self, contracts, date_from, date_to):
|
||||
leaves = {}
|
||||
|
||||
for contract in contracts.filtered(lambda c: c.paid_hourly_timesheet):
|
||||
for leave in self._fetch_valid_leaves_timesheet(contract.employee_id.id, date_from, date_to):
|
||||
leave_code = self._create_leave_code(leave.holiday_status_id.name)
|
||||
if leave_code in leaves:
|
||||
leaves[leave_code]['number_of_days'] += leave.number_of_days_temp
|
||||
leaves[leave_code]['number_of_hours'] += leave.number_of_days_temp * HOURS_PER_DAY
|
||||
else:
|
||||
leaves[leave_code] = {
|
||||
'name': leave.holiday_status_id.name,
|
||||
'sequence': 15,
|
||||
'code': leave_code,
|
||||
'number_of_days': leave.number_of_days_temp,
|
||||
'number_of_hours': leave.number_of_days_temp * HOURS_PER_DAY,
|
||||
'contract_id': contract.id,
|
||||
}
|
||||
|
||||
res = super(HrPayslip, self).get_worked_day_lines(contracts, date_from, date_to)
|
||||
res.extend(leaves.values())
|
||||
return res
|
||||
|
||||
@api.multi
|
||||
def action_payslip_done(self):
|
||||
for slip in self.filtered(lambda s: s.contract_id.paid_hourly_timesheet):
|
||||
leaves = self._fetch_valid_leaves_timesheet(slip.employee_id.id, slip.date_from, slip.date_to)
|
||||
leaves.write({'payslip_status': True})
|
||||
return super(HrPayslip, self).action_payslip_done()
|
||||
|
||||
def _fetch_valid_leaves_timesheet(self, employee_id, date_from, date_to):
|
||||
valid_leaves = [
|
||||
('employee_id', '=', employee_id),
|
||||
('state', '=', 'validate'),
|
||||
('date_from', '>=', date_from),
|
||||
('date_from', '<=', date_to),
|
||||
('payslip_status', '=', False),
|
||||
('type', '=', 'remove'),
|
||||
]
|
||||
|
||||
return self.env['hr.holidays'].search(valid_leaves)
|
||||
|
||||
def _create_leave_code(self, name):
|
||||
return 'L_' + name.replace(' ', '_')
|
||||
3
hr_payroll_timesheet_old/__init__.py
Executable file
3
hr_payroll_timesheet_old/__init__.py
Executable file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import hr_payslip
|
||||
from . import hr_contract
|
||||
18
hr_payroll_timesheet_old/__manifest__.py
Executable file
18
hr_payroll_timesheet_old/__manifest__.py
Executable file
@@ -0,0 +1,18 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
{
|
||||
'name': 'Timesheets on Payslips',
|
||||
'description': 'Get Timesheet and Attendence numbers onto Employee Payslips.',
|
||||
'version': '11.0.0.0.0',
|
||||
'website': 'https://hibou.io/',
|
||||
'author': 'Hibou Corp. <hello@hibou.io>',
|
||||
'license': 'AGPL-3',
|
||||
'category': 'Human Resources',
|
||||
'data': [
|
||||
'hr_contract_view.xml',
|
||||
],
|
||||
'depends': [
|
||||
'hr_payroll',
|
||||
'hr_timesheet_attendance',
|
||||
],
|
||||
}
|
||||
26
mrp_production_add/README.rst
Executable file
26
mrp_production_add/README.rst
Executable file
@@ -0,0 +1,26 @@
|
||||
*******************************
|
||||
Hibou - MRP Production Add Item
|
||||
*******************************
|
||||
|
||||
Allows a user to add a new item to an in-progress Manufacturing Order (including generating PO procurements).
|
||||
For more information and add-ons, visit `Hibou.io <https://hibou.io/>`_.
|
||||
|
||||
.. image:: https://cloud.githubusercontent.com/assets/744550/20810612/2f3eb514-b7bf-11e6-838f-6d6efb8f7484.png
|
||||
:alt: 'MRP Production Add'
|
||||
:width: 988
|
||||
:align: left
|
||||
=============
|
||||
Main Features
|
||||
=============
|
||||
|
||||
* Button above existing Consumed Materials to add new product.
|
||||
* Uses existing procurement group and routes to procure additional items.
|
||||
|
||||
|
||||
=======
|
||||
Licence
|
||||
=======
|
||||
|
||||
Please see `LICENSE <https://github.com/hibou-io/hibou-odoo-suite/blob/master/LICENSE>`_.
|
||||
|
||||
Copyright Hibou Corp. 2016.
|
||||
1
mrp_production_add/__init__.py
Executable file
1
mrp_production_add/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
from . import wizard
|
||||
17
mrp_production_add/__manifest__.py
Executable file
17
mrp_production_add/__manifest__.py
Executable file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
'name': 'MRP Production Add Item',
|
||||
'author': 'Hibou Corp. <hello@hibou.io>',
|
||||
'version': '11.0.1.0.0',
|
||||
'category': 'Manufacturing',
|
||||
'summary': 'Add Items to an existing Production',
|
||||
'description': """
|
||||
This module allows a production order to add additional items that are not on the product's BoM.
|
||||
""",
|
||||
'website': 'https://hibou.io/',
|
||||
'depends': ['mrp'],
|
||||
'data': [
|
||||
'wizard/additem_wizard_view.xml',
|
||||
'views/mrp_production.xml',
|
||||
],
|
||||
'installable': True,
|
||||
}
|
||||
21
mrp_production_add/views/mrp_production.xml
Executable file
21
mrp_production_add/views/mrp_production.xml
Executable file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record id="mrp_production_add_production_item_form_view" model="ir.ui.view">
|
||||
<field name="name">mrp.production.add_production_item.form.view</field>
|
||||
<field name="model">mrp.production</field>
|
||||
<field name="inherit_id" ref="mrp.mrp_production_form_view" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='move_raw_ids']" position="before">
|
||||
<group col="2" colspan="2">
|
||||
<button name="%(action_add_production_item)d"
|
||||
type="action"
|
||||
attrs="{'invisible': [('state', 'in', ('cancel', 'done'))]}"
|
||||
string="Add extra item"
|
||||
class="oe_highlight"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
1
mrp_production_add/wizard/__init__.py
Executable file
1
mrp_production_add/wizard/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
from . import additem_wizard
|
||||
50
mrp_production_add/wizard/additem_wizard.py
Executable file
50
mrp_production_add/wizard/additem_wizard.py
Executable file
@@ -0,0 +1,50 @@
|
||||
from odoo import api, fields, models
|
||||
from odoo.addons import decimal_precision as dp
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class AddProductionItem(models.TransientModel):
|
||||
_name = 'wiz.add.production.item'
|
||||
_description = 'Add Production Item'
|
||||
|
||||
@api.model
|
||||
def _default_production_id(self):
|
||||
return self.env.context.get('active_id', False)
|
||||
|
||||
product_id = fields.Many2one('product.product', 'Product', required=True)
|
||||
product_qty = fields.Float(
|
||||
'Product Quantity', digits=dp.get_precision('Product Unit of Measure'),
|
||||
required=True,
|
||||
default=1.0)
|
||||
product_uom_id = fields.Many2one('product.uom', 'Unit of Measure')
|
||||
production_id = fields.Many2one(
|
||||
'mrp.production', 'Production Order',
|
||||
default=_default_production_id)
|
||||
|
||||
@api.onchange('product_id')
|
||||
def _onchange_product_id(self):
|
||||
for item in self:
|
||||
if item.product_id:
|
||||
item.product_uom_id = item.product_id.uom_id
|
||||
else:
|
||||
item.product_uom_id = False
|
||||
|
||||
@api.multi
|
||||
def add_item(self):
|
||||
for item in self:
|
||||
if item.product_qty <= 0:
|
||||
raise UserError('Please provide a positive quantity to add')
|
||||
|
||||
bom_line = self.env['mrp.bom.line'].new({
|
||||
'product_id': item.product_id.id,
|
||||
'product_qty': item.product_qty,
|
||||
'bom_id': item.production_id.bom_id.id,
|
||||
'product_uom_id': item.product_uom_id.id,
|
||||
})
|
||||
|
||||
move = item.production_id._generate_raw_move(bom_line, {'qty': item.product_qty, 'parent_line': None})
|
||||
item.production_id._adjust_procure_method()
|
||||
move.write({'unit_factor': 0.0})
|
||||
move._action_confirm()
|
||||
|
||||
return True
|
||||
38
mrp_production_add/wizard/additem_wizard_view.xml
Executable file
38
mrp_production_add/wizard/additem_wizard_view.xml
Executable file
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<record id="view_create_add_production_item" model="ir.ui.view">
|
||||
<field name="name">view.create.add_production_item</field>
|
||||
<field name="model">wiz.add.production.item</field>
|
||||
<field name="type">form</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<group colspan="4" col="4">
|
||||
<separator string="Add Product" colspan="4"/>
|
||||
<field name="product_id" colspan="2"/>
|
||||
<field name="product_uom_id" colspan="2"/>
|
||||
<field name="product_qty" colspan="2"/>
|
||||
<field name="production_id" colspan="2"
|
||||
invisible="context.get('active_id')"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button class="oe_highlight"
|
||||
name="add_item"
|
||||
string="Add"
|
||||
type="object" />
|
||||
or
|
||||
<button class="oe_link"
|
||||
special="cancel"
|
||||
string="Cancel" />
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
<record id="action_add_production_item" model="ir.actions.act_window">
|
||||
<field name="name">Add Item View</field>
|
||||
<field name="res_model">wiz.add.production.item</field>
|
||||
<field name="view_type">form</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="view_id" ref="view_create_add_production_item" />
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
</odoo>
|
||||
1
product_catch_weight/__init__.py
Normal file
1
product_catch_weight/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
20
product_catch_weight/__manifest__.py
Normal file
20
product_catch_weight/__manifest__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
'name': 'Product Catch Weight',
|
||||
'version': '11.0.1.0.0',
|
||||
'category': 'Warehouse',
|
||||
'depends': [
|
||||
'sale_stock',
|
||||
'purchase',
|
||||
],
|
||||
'description': """
|
||||
""",
|
||||
'author': 'Hibou Corp.',
|
||||
'license': 'AGPL-3',
|
||||
'website': 'https://hibou.io/',
|
||||
'data': [
|
||||
'views/account_invoice_views.xml',
|
||||
'views/stock_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
}
|
||||
4
product_catch_weight/models/__init__.py
Normal file
4
product_catch_weight/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from . import account_invoice
|
||||
from . import product
|
||||
from . import stock_patch
|
||||
from . import stock
|
||||
48
product_catch_weight/models/account_invoice.py
Normal file
48
product_catch_weight/models/account_invoice.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from odoo import api, fields, models
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AccountInvoiceLine(models.Model):
|
||||
_inherit = 'account.invoice.line'
|
||||
|
||||
catch_weight = fields.Float(string='Catch Weight', digits=(10, 4), compute='_compute_price', store=True)
|
||||
catch_weight_uom_id = fields.Many2one('product.uom', related='product_id.catch_weight_uom_id')
|
||||
|
||||
@api.one
|
||||
@api.depends('price_unit', 'discount', 'invoice_line_tax_ids', 'quantity',
|
||||
'product_id', 'invoice_id.partner_id', 'invoice_id.currency_id', 'invoice_id.company_id',
|
||||
'invoice_id.date_invoice', 'invoice_id.date')
|
||||
def _compute_price(self):
|
||||
currency = self.invoice_id and self.invoice_id.currency_id or None
|
||||
price = self.price_unit * (1 - (self.discount or 0.0) / 100.0)
|
||||
|
||||
ratio = 1.0
|
||||
qty_done_total = 0.0
|
||||
catch_weight = 0.0
|
||||
if self.invoice_id.type in ('out_invoice', 'out_refund'):
|
||||
move_lines = self.sale_line_ids.mapped('move_ids.move_line_ids')
|
||||
else:
|
||||
move_lines = self.purchase_line_id.mapped('move_ids.move_line_ids')
|
||||
for move_line in move_lines:
|
||||
qty_done = move_line.qty_done
|
||||
r = move_line.lot_id.catch_weight_ratio
|
||||
ratio = ((ratio * qty_done_total) + (qty_done * r)) / (qty_done + qty_done_total)
|
||||
qty_done_total += qty_done
|
||||
catch_weight += move_line.lot_id.catch_weight
|
||||
price = price * ratio
|
||||
self.catch_weight = catch_weight
|
||||
|
||||
taxes = False
|
||||
if self.invoice_line_tax_ids:
|
||||
taxes = self.invoice_line_tax_ids.compute_all(price, currency, self.quantity, product=self.product_id,
|
||||
partner=self.invoice_id.partner_id)
|
||||
self.price_subtotal = price_subtotal_signed = taxes['total_excluded'] if taxes else self.quantity * price
|
||||
self.price_total = taxes['total_included'] if taxes else self.price_subtotal
|
||||
if self.invoice_id.currency_id and self.invoice_id.currency_id != self.invoice_id.company_id.currency_id:
|
||||
price_subtotal_signed = self.invoice_id.currency_id.with_context(
|
||||
date=self.invoice_id._get_currency_rate_date()).compute(price_subtotal_signed,
|
||||
self.invoice_id.company_id.currency_id)
|
||||
sign = self.invoice_id.type in ['in_refund', 'out_refund'] and -1 or 1
|
||||
self.price_subtotal_signed = price_subtotal_signed * sign
|
||||
7
product_catch_weight/models/product.py
Normal file
7
product_catch_weight/models/product.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ProductProduct(models.Model):
|
||||
_inherit = 'product.template'
|
||||
|
||||
catch_weight_uom_id = fields.Many2one('product.uom', string='Catch Weight UOM')
|
||||
46
product_catch_weight/models/stock.py
Normal file
46
product_catch_weight/models/stock.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class StockProductionLot(models.Model):
|
||||
_inherit = 'stock.production.lot'
|
||||
|
||||
catch_weight_ratio = fields.Float(string='Catch Weight Ratio', digits=(10, 6), compute='_compute_catch_weight_ratio')
|
||||
catch_weight = fields.Float(string='Catch Weight', digits=(10, 4))
|
||||
catch_weight_uom_id = fields.Many2one('product.uom', related='product_id.catch_weight_uom_id')
|
||||
|
||||
|
||||
@api.depends('catch_weight')
|
||||
def _compute_catch_weight_ratio(self):
|
||||
for lot in self:
|
||||
if not lot.catch_weight_uom_id:
|
||||
lot.catch_weight_ratio = 1.0
|
||||
else:
|
||||
lot.catch_weight_ratio = lot.catch_weight_uom_id._compute_quantity(lot.catch_weight,
|
||||
lot.product_id.uom_id,
|
||||
rounding_method='DOWN')
|
||||
|
||||
|
||||
class StockMove(models.Model):
|
||||
_inherit = 'stock.move'
|
||||
|
||||
product_catch_weight_uom_id = fields.Many2one('product.uom', related="product_id.catch_weight_uom_id")
|
||||
|
||||
def _prepare_move_line_vals(self, quantity=None, reserved_quant=None):
|
||||
vals = super(StockMove, self)._prepare_move_line_vals(quantity=quantity, reserved_quant=reserved_quant)
|
||||
vals['catch_weight_uom_id'] = self.product_catch_weight_uom_id.id if self.product_catch_weight_uom_id else False
|
||||
return vals
|
||||
|
||||
def action_show_details(self):
|
||||
action = super(StockMove, self).action_show_details()
|
||||
action['context']['show_catch_weight'] = bool(self.product_id.catch_weight_uom_id)
|
||||
return action
|
||||
|
||||
|
||||
class StockMoveLine(models.Model):
|
||||
_inherit = 'stock.move.line'
|
||||
|
||||
catch_weight_ratio = fields.Float(string='Catch Weight Ratio', digits=(10, 6), default=1.0)
|
||||
catch_weight = fields.Float(string='Catch Weight', digits=(10,4))
|
||||
catch_weight_uom_id = fields.Many2one('product.uom', string='Catch Weight UOM')
|
||||
lot_catch_weight = fields.Float(related='lot_id.catch_weight')
|
||||
lot_catch_weight_uom_id = fields.Many2one('product.uom', related='product_id.catch_weight_uom_id')
|
||||
115
product_catch_weight/models/stock_patch.py
Normal file
115
product_catch_weight/models/stock_patch.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from odoo import fields
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools.float_utils import float_round, float_compare, float_is_zero
|
||||
from odoo.addons.stock.models.stock_move_line import StockMoveLine
|
||||
|
||||
|
||||
def _action_done(self):
|
||||
""" This method is called during a move's `action_done`. It'll actually move a quant from
|
||||
the source location to the destination location, and unreserve if needed in the source
|
||||
location.
|
||||
|
||||
This method is intended to be called on all the move lines of a move. This method is not
|
||||
intended to be called when editing a `done` move (that's what the override of `write` here
|
||||
is done.
|
||||
"""
|
||||
|
||||
# First, we loop over all the move lines to do a preliminary check: `qty_done` should not
|
||||
# be negative and, according to the presence of a picking type or a linked inventory
|
||||
# adjustment, enforce some rules on the `lot_id` field. If `qty_done` is null, we unlink
|
||||
# the line. It is mandatory in order to free the reservation and correctly apply
|
||||
# `action_done` on the next move lines.
|
||||
ml_to_delete = self.env['stock.move.line']
|
||||
for ml in self:
|
||||
# Check here if `ml.qty_done` respects the rounding of `ml.product_uom_id`.
|
||||
uom_qty = float_round(ml.qty_done, precision_rounding=ml.product_uom_id.rounding, rounding_method='HALF-UP')
|
||||
precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
||||
qty_done = float_round(ml.qty_done, precision_digits=precision_digits, rounding_method='HALF-UP')
|
||||
if float_compare(uom_qty, qty_done, precision_digits=precision_digits) != 0:
|
||||
raise UserError(_('The quantity done for the product "%s" doesn\'t respect the rounding precision \
|
||||
defined on the unit of measure "%s". Please change the quantity done or the \
|
||||
rounding precision of your unit of measure.') % (
|
||||
ml.product_id.display_name, ml.product_uom_id.name))
|
||||
|
||||
qty_done_float_compared = float_compare(ml.qty_done, 0, precision_rounding=ml.product_uom_id.rounding)
|
||||
if qty_done_float_compared > 0:
|
||||
if ml.product_id.tracking != 'none':
|
||||
picking_type_id = ml.move_id.picking_type_id
|
||||
if picking_type_id:
|
||||
if picking_type_id.use_create_lots:
|
||||
# If a picking type is linked, we may have to create a production lot on
|
||||
# the fly before assigning it to the move line if the user checked both
|
||||
# `use_create_lots` and `use_existing_lots`.
|
||||
if ml.lot_name and not ml.lot_id:
|
||||
lot_catch_weight = ml.catch_weight_uom_id._compute_quantity(ml.catch_weight, ml.product_id.catch_weight_uom_id, rounding_method='DOWN')
|
||||
lot = self.env['stock.production.lot'].create(
|
||||
{'name': ml.lot_name, 'product_id': ml.product_id.id, 'catch_weight': lot_catch_weight}
|
||||
)
|
||||
ml.write({'lot_id': lot.id})
|
||||
elif not picking_type_id.use_create_lots and not picking_type_id.use_existing_lots:
|
||||
# If the user disabled both `use_create_lots` and `use_existing_lots`
|
||||
# checkboxes on the picking type, he's allowed to enter tracked
|
||||
# products without a `lot_id`.
|
||||
continue
|
||||
elif ml.move_id.inventory_id:
|
||||
# If an inventory adjustment is linked, the user is allowed to enter
|
||||
# tracked products without a `lot_id`.
|
||||
continue
|
||||
|
||||
if not ml.lot_id:
|
||||
raise UserError(_('You need to supply a lot/serial number for %s.') % ml.product_id.name)
|
||||
elif qty_done_float_compared < 0:
|
||||
raise UserError(_('No negative quantities allowed'))
|
||||
else:
|
||||
ml_to_delete |= ml
|
||||
ml_to_delete.unlink()
|
||||
|
||||
# Now, we can actually move the quant.
|
||||
done_ml = self.env['stock.move.line']
|
||||
for ml in self - ml_to_delete:
|
||||
if ml.product_id.type == 'product':
|
||||
Quant = self.env['stock.quant']
|
||||
rounding = ml.product_uom_id.rounding
|
||||
|
||||
# if this move line is force assigned, unreserve elsewhere if needed
|
||||
if not ml.location_id.should_bypass_reservation() and float_compare(ml.qty_done, ml.product_qty,
|
||||
precision_rounding=rounding) > 0:
|
||||
extra_qty = ml.qty_done - ml.product_qty
|
||||
ml._free_reservation(ml.product_id, ml.location_id, extra_qty, lot_id=ml.lot_id,
|
||||
package_id=ml.package_id, owner_id=ml.owner_id, ml_to_ignore=done_ml)
|
||||
# unreserve what's been reserved
|
||||
if not ml.location_id.should_bypass_reservation() and ml.product_id.type == 'product' and ml.product_qty:
|
||||
try:
|
||||
Quant._update_reserved_quantity(ml.product_id, ml.location_id, -ml.product_qty, lot_id=ml.lot_id,
|
||||
package_id=ml.package_id, owner_id=ml.owner_id, strict=True)
|
||||
except UserError:
|
||||
Quant._update_reserved_quantity(ml.product_id, ml.location_id, -ml.product_qty, lot_id=False,
|
||||
package_id=ml.package_id, owner_id=ml.owner_id, strict=True)
|
||||
|
||||
# move what's been actually done
|
||||
quantity = ml.product_uom_id._compute_quantity(ml.qty_done, ml.move_id.product_id.uom_id,
|
||||
rounding_method='HALF-UP')
|
||||
available_qty, in_date = Quant._update_available_quantity(ml.product_id, ml.location_id, -quantity,
|
||||
lot_id=ml.lot_id, package_id=ml.package_id,
|
||||
owner_id=ml.owner_id)
|
||||
if available_qty < 0 and ml.lot_id:
|
||||
# see if we can compensate the negative quants with some untracked quants
|
||||
untracked_qty = Quant._get_available_quantity(ml.product_id, ml.location_id, lot_id=False,
|
||||
package_id=ml.package_id, owner_id=ml.owner_id,
|
||||
strict=True)
|
||||
if untracked_qty:
|
||||
taken_from_untracked_qty = min(untracked_qty, abs(quantity))
|
||||
Quant._update_available_quantity(ml.product_id, ml.location_id, -taken_from_untracked_qty,
|
||||
lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id)
|
||||
Quant._update_available_quantity(ml.product_id, ml.location_id, taken_from_untracked_qty,
|
||||
lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id)
|
||||
Quant._update_available_quantity(ml.product_id, ml.location_dest_id, quantity, lot_id=ml.lot_id,
|
||||
package_id=ml.result_package_id, owner_id=ml.owner_id, in_date=in_date)
|
||||
done_ml |= ml
|
||||
# Reset the reserved quantity as we just moved it to the destination location.
|
||||
(self - ml_to_delete).with_context(bypass_reservation_update=True).write({
|
||||
'product_uom_qty': 0.00,
|
||||
'date': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
StockMoveLine._action_done = _action_done
|
||||
1
product_catch_weight/tests/__init__.py
Normal file
1
product_catch_weight/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_catch_weight
|
||||
158
product_catch_weight/tests/test_catch_weight.py
Normal file
158
product_catch_weight/tests/test_catch_weight.py
Normal file
@@ -0,0 +1,158 @@
|
||||
import logging
|
||||
# from odoo.addons.stock.tests.test_move2 import TestPickShip
|
||||
from odoo import fields
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TestPicking(TransactionCase):
|
||||
def setUp(self):
|
||||
super(TestPicking, self).setUp()
|
||||
self.nominal_weight = 50.0
|
||||
self.partner1 = self.env.ref('base.res_partner_2')
|
||||
self.stock_location = self.env.ref('stock.stock_location_stock')
|
||||
self.ref_uom_id = self.env.ref('product.product_uom_kgm')
|
||||
self.product_uom_id = self.env['product.uom'].create({
|
||||
'name': '50 ref',
|
||||
'category_id': self.ref_uom_id.category_id.id,
|
||||
'uom_type': 'bigger',
|
||||
'factor_inv': self.nominal_weight,
|
||||
})
|
||||
self.product1 = self.env['product.product'].create({
|
||||
'name': 'Product 1',
|
||||
'type': 'product',
|
||||
'tracking': 'serial',
|
||||
'list_price': 100.0,
|
||||
'standard_price': 50.0,
|
||||
'taxes_id': [(5, 0, 0)],
|
||||
'uom_id': self.product_uom_id.id,
|
||||
'uom_po_id': self.product_uom_id.id,
|
||||
'catch_weight_uom_id': self.ref_uom_id.id,
|
||||
})
|
||||
|
||||
|
||||
# def test_creation(self):
|
||||
# self.productA.tracking = 'serial'
|
||||
# lot = self.env['stock.production.lot'].create({
|
||||
# 'product_id': self.productA.id,
|
||||
# 'name': '123456789',
|
||||
# })
|
||||
#
|
||||
# lot.catch_weight_ratio = 0.8
|
||||
# _logger.warn(lot.xxxcatch_weight_ratio)
|
||||
|
||||
|
||||
|
||||
# def test_delivery(self):
|
||||
# self.productA.tracking = 'serial'
|
||||
# picking_pick, picking_pack, picking_ship = self.create_pick_pack_ship()
|
||||
# stock_location = self.env['stock.location'].browse(self.stock_location)
|
||||
# lot = self.env['stock.production.lot'].create({
|
||||
# 'product_id': self.productA.id,
|
||||
# 'name': '123456789',
|
||||
# 'catch_weight_ratio': 0.8,
|
||||
# })
|
||||
# self.env['stock.quant']._update_available_quantity(self.productA, stock_location, 1.0, lot_id=lot)
|
||||
|
||||
def test_so_invoice(self):
|
||||
ref_weight = 45.0
|
||||
lot = self.env['stock.production.lot'].create({
|
||||
'product_id': self.product1.id,
|
||||
'name': '123456789',
|
||||
'catch_weight': ref_weight,
|
||||
})
|
||||
self.assertAlmostEqual(lot.catch_weight_ratio, ref_weight / self.nominal_weight)
|
||||
self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1.0, lot_id=lot)
|
||||
so = 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})],
|
||||
})
|
||||
so.action_confirm()
|
||||
self.assertTrue(so.state in ('sale', 'done'))
|
||||
self.assertEqual(len(so.picking_ids), 1)
|
||||
picking = so.picking_ids
|
||||
self.assertEqual(picking.state, 'assigned')
|
||||
self.assertEqual(picking.move_lines.move_line_ids.lot_id, lot)
|
||||
picking.move_lines.move_line_ids.qty_done = 1.0
|
||||
picking.button_validate()
|
||||
self.assertEqual(picking.state, 'done')
|
||||
|
||||
inv_id = so.action_invoice_create()
|
||||
inv = self.env['account.invoice'].browse(inv_id)
|
||||
self.assertAlmostEqual(inv.amount_total, lot.catch_weight_ratio * self.product1.list_price)
|
||||
|
||||
def test_so_invoice2(self):
|
||||
ref_weight1 = 45.0
|
||||
ref_weight2 = 51.0
|
||||
lot1 = self.env['stock.production.lot'].create({
|
||||
'product_id': self.product1.id,
|
||||
'name': '1-low',
|
||||
'catch_weight': ref_weight1,
|
||||
})
|
||||
lot2 = self.env['stock.production.lot'].create({
|
||||
'product_id': self.product1.id,
|
||||
'name': '1-high',
|
||||
'catch_weight': ref_weight2,
|
||||
})
|
||||
self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1.0, lot_id=lot1)
|
||||
self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1.0, lot_id=lot2)
|
||||
so = 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': 2.0})],
|
||||
})
|
||||
so.action_confirm()
|
||||
self.assertTrue(so.state in ('sale', 'done'))
|
||||
self.assertEqual(len(so.picking_ids), 1)
|
||||
picking = so.picking_ids
|
||||
self.assertEqual(picking.state, 'assigned')
|
||||
self.assertEqual(picking.move_lines.move_line_ids.mapped('lot_id'), lot1 + lot2)
|
||||
for line in picking.move_lines.move_line_ids:
|
||||
line.qty_done = 1.0
|
||||
picking.button_validate()
|
||||
self.assertEqual(picking.state, 'done')
|
||||
|
||||
inv_id = so.action_invoice_create()
|
||||
inv = self.env['account.invoice'].browse(inv_id)
|
||||
self.assertAlmostEqual(inv.amount_total, self.product1.list_price * (lot1.catch_weight_ratio + lot2.catch_weight_ratio))
|
||||
|
||||
def test_po_invoice(self):
|
||||
ref_weight1 = 45.0
|
||||
ref_weight2 = 51.0
|
||||
weights = (ref_weight1, ref_weight2)
|
||||
price = self.product1.standard_price
|
||||
po = self.env['purchase.order'].create({
|
||||
'partner_id': self.partner1.id,
|
||||
'order_line': [(0, 0, {
|
||||
'product_id': self.product1.id,
|
||||
'product_qty': 2.0,
|
||||
'name': 'Test',
|
||||
'date_planned': fields.Datetime.now(),
|
||||
'product_uom': self.product1.uom_po_id.id,
|
||||
'price_unit': price,
|
||||
})]
|
||||
})
|
||||
po.button_confirm()
|
||||
self.assertEqual(po.state, 'purchase')
|
||||
self.assertEqual(len(po.picking_ids), 1)
|
||||
|
||||
picking = po.picking_ids
|
||||
for i, line in enumerate(picking.move_lines.move_line_ids):
|
||||
line.write({'lot_name': str(i), 'qty_done': 1.0, 'catch_weight': weights[i]})
|
||||
picking.button_validate()
|
||||
self.assertEqual(picking.state, 'done')
|
||||
|
||||
inv = self.env['account.invoice'].create({
|
||||
'type': 'in_invoice',
|
||||
'partner_id': self.partner1.id,
|
||||
'purchase_id': po.id,
|
||||
})
|
||||
inv.purchase_order_change()
|
||||
self.assertEqual(len(inv.invoice_line_ids), 1)
|
||||
self.assertEqual(inv.invoice_line_ids.quantity, 2.0)
|
||||
self.assertAlmostEqual(inv.amount_total, price * sum(w / self.nominal_weight for w in weights))
|
||||
|
||||
69
product_catch_weight/views/account_invoice_views.xml
Normal file
69
product_catch_weight/views/account_invoice_views.xml
Normal file
@@ -0,0 +1,69 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="invoice_form_inherit" model="ir.ui.view">
|
||||
<field name="name">account.invoice.form.inherit</field>
|
||||
<field name="model">account.invoice</field>
|
||||
<field name="inherit_id" ref="account.invoice_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//tree/field[@name='price_unit']" position="after">
|
||||
<field name="catch_weight" attrs="{'invisible': [('catch_weight_uom_id', '=', False)]}"/>
|
||||
<field name="catch_weight_uom_id" readonly="1" attrs="{'invisible': [('catch_weight_uom_id', '=', False)]}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
<record id="invoice_supplier_form_inherit" model="ir.ui.view">
|
||||
<field name="name">account.invoice.supplier.form.inherit</field>
|
||||
<field name="model">account.invoice</field>
|
||||
<field name="inherit_id" ref="account.invoice_supplier_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//tree/field[@name='price_unit']" position="after">
|
||||
<field name="catch_weight" attrs="{'invisible': [('catch_weight_uom_id', '=', False)]}"/>
|
||||
<field name="catch_weight_uom_id" readonly="1" attrs="{'invisible': [('catch_weight_uom_id', '=', False)]}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
<template id="report_invoice_document_inherit" name="report_invoice_document_catch_weight" inherit_id="account.report_invoice_document">
|
||||
<xpath expr="//thead/tr/th[4]" position="after">
|
||||
<t t-if="o.invoice_line_ids.filtered(lambda l: l.catch_weight_uom_id)">
|
||||
<th class="text-right">Catch Weight</th>
|
||||
<th class="text-right">CW Unit Price</th>
|
||||
</t>
|
||||
</xpath>
|
||||
<xpath expr="//tbody/tr[1]/td[4]" position="after">
|
||||
<t t-if="o.invoice_line_ids.filtered(lambda l: l.catch_weight_uom_id)">
|
||||
<t t-if="l.catch_weight_uom_id">
|
||||
<td class="text-right">
|
||||
<strong t-field="l.catch_weight"/>
|
||||
<span t-field="l.catch_weight_uom_id"/>
|
||||
<hr style="padding: 0; margin: 0;"/>
|
||||
<t t-if="o.type in ('out_invoice', 'out_refund')" t-set="lots" t-value="l.sale_line_ids.mapped('move_ids.move_line_ids.lot_id')"/>
|
||||
<t t-else="" t-set="lots" t-value="l.purchase_line_id.mapped('move_ids.move_line_ids.lot_id')"/>
|
||||
<ul class="list-unstyled">
|
||||
<li t-foreach="lots" t-as="lot">
|
||||
<span t-field="lot.name"/>: <span t-field="lot.catch_weight"/>
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<span t-esc="'{:0.2f}'.format(l.uom_id._compute_price(l.price_unit, l.catch_weight_uom_id))"/>
|
||||
/
|
||||
<span t-field="l.catch_weight_uom_id"/>
|
||||
</td>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<td/>
|
||||
<td/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</xpath>
|
||||
<xpath expr="//tbody/tr[2]/td[4]" position="after">
|
||||
<t t-if="o.invoice_line_ids.filtered(lambda l: l.catch_weight_uom_id)">
|
||||
<td/>
|
||||
<td/>
|
||||
</t>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
62
product_catch_weight/views/stock_views.xml
Normal file
62
product_catch_weight/views/stock_views.xml
Normal file
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="view_production_lot_form_inherit" model="ir.ui.view">
|
||||
<field name="name">stock.production.lot.form.inherit</field>
|
||||
<field name="model">stock.production.lot</field>
|
||||
<field name="inherit_id" ref="stock.view_production_lot_form" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='product_id']" position="after">
|
||||
<field name="catch_weight_ratio"/>
|
||||
<field name="catch_weight"/>
|
||||
<field name="catch_weight_uom_id" readonly="1"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!--<record id="view_move_line_form_inherit" model="ir.ui.view">-->
|
||||
<!--<field name="name">stock.move.line.form.inherit</field>-->
|
||||
<!--<field name="model">stock.move.line</field>-->
|
||||
<!--<field name="inherit_id" ref="stock.view_move_line_form" />-->
|
||||
<!--<field name="arch" type="xml">-->
|
||||
<!--<xpath expr="//field[@name='lot_name']" position="after">-->
|
||||
<!--<field name="lot_catch_weight_ratio" readonly="1"/>-->
|
||||
<!--</xpath>-->
|
||||
<!--</field>-->
|
||||
<!--</record>-->
|
||||
<record id="view_stock_move_operations_inherit" model="ir.ui.view">
|
||||
<field name="name">stock.move.operations.form.inherit</field>
|
||||
<field name="model">stock.move</field>
|
||||
<field name="inherit_id" ref="stock.view_stock_move_operations"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='location_dest_id']" position="after">
|
||||
<field name="product_catch_weight_uom_id" invisible="1"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='move_line_ids']" position="attributes">
|
||||
<attribute name="context">{'tree_view_ref': 'stock.view_stock_move_line_operation_tree', 'default_product_uom_id': product_uom, 'default_picking_id': picking_id, 'default_move_id': id, 'default_product_id': product_id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id, 'default_catch_weight_uom_id': product_catch_weight_uom_id}</attribute>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
<record id="view_stock_move_line_operation_tree_inherit" model="ir.ui.view">
|
||||
<field name="name">stock.move.line.operations.tree.inherit</field>
|
||||
<field name="model">stock.move.line</field>
|
||||
<field name="inherit_id" ref="stock.view_stock_move_line_operation_tree" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='lot_name']" position="after">
|
||||
<field name="catch_weight" invisible="not context.get('show_lots_text') or not context.get('show_catch_weight')"/>
|
||||
<field name="catch_weight_uom_id" invisible="not context.get('show_lots_text') or not context.get('show_catch_weight')"/>
|
||||
<field name="lot_catch_weight" invisible="not context.get('show_lots_m2o') or not context.get('show_catch_weight')" readonly="1"/>
|
||||
<field name="lot_catch_weight_uom_id" invisible="not context.get('show_lots_m2o') or not context.get('show_catch_weight')" readonly="1"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
<record id="product_template_form_view_inherit" model="ir.ui.view">
|
||||
<field name="name">product.template.common.form.inherit</field>
|
||||
<field name="model">product.template</field>
|
||||
<field name="inherit_id" ref="product.product_template_form_view" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='uom_po_id']" position="after">
|
||||
<field name="catch_weight_uom_id" attrs="{'invisible': [('tracking', '!=', 'serial')]}" help="Leave empty to not use catch weight."/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
1
project_task_line/__init__.py
Normal file
1
project_task_line/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
20
project_task_line/__manifest__.py
Normal file
20
project_task_line/__manifest__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
'name': 'Project Task Lines',
|
||||
'version': '11.0.1.0.0',
|
||||
'author': 'Hibou Corp. <hello@hibou.io>',
|
||||
'website': 'https://hibou.io/',
|
||||
'license': 'AGPL-3',
|
||||
'category': 'Tools',
|
||||
'complexity': 'easy',
|
||||
'description': """
|
||||
Adds "todo" lines onto Project Tasks, and improves sub-tasks.
|
||||
""",
|
||||
'depends': [
|
||||
'project',
|
||||
],
|
||||
'data': [
|
||||
'views/project_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
}
|
||||
1
project_task_line/models/__init__.py
Normal file
1
project_task_line/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import project
|
||||
39
project_task_line/models/project.py
Normal file
39
project_task_line/models/project.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ProjectTask(models.Model):
|
||||
_inherit = 'project.task'
|
||||
|
||||
line_ids = fields.One2many('project.task.line', 'task_id', string='Todo List')
|
||||
subtask_count_done = fields.Integer(compute='_compute_subtask_count', string="Sub-task Done count")
|
||||
|
||||
@api.multi
|
||||
def _compute_subtask_count(self):
|
||||
for task in self:
|
||||
task.subtask_count = self.search_count([('id', 'child_of', task.id), ('id', '!=', task.id)])
|
||||
task.subtask_count_done = self.search_count([('id', 'child_of', task.id), ('id', '!=', task.id), ('stage_id.fold', '=', True)])
|
||||
|
||||
|
||||
class ProjectTaskLine(models.Model):
|
||||
_name = 'project.task.line'
|
||||
_description = 'Task Todos'
|
||||
_order = 'sequence, id desc'
|
||||
|
||||
task_id = fields.Many2one('project.task', required=True)
|
||||
name = fields.Char(string='Name')
|
||||
user_id = fields.Many2one('res.users', string='User')
|
||||
sequence = fields.Integer(string='Sequence')
|
||||
kanban_state = fields.Selection([
|
||||
('normal', 'Grey'),
|
||||
('done', 'Green'),
|
||||
('blocked', 'Red')], string='Kanban State',
|
||||
copy=False, default='normal', required=True,
|
||||
help="A task's kanban state indicates special situations affecting it:\n"
|
||||
" * Grey is the default situation\n"
|
||||
" * Red indicates something is preventing the progress of this task\n"
|
||||
" * Green indicates the task is complete")
|
||||
|
||||
@api.onchange('kanban_state')
|
||||
def _onchange_kanban_state(self):
|
||||
if self.kanban_state == 'done':
|
||||
self.user_id = self.env.user
|
||||
42
project_task_line/views/project_views.xml
Normal file
42
project_task_line/views/project_views.xml
Normal file
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<!--<record id="edit_project_inherit" model="ir.ui.view">-->
|
||||
<!--<field name="name">project.project.form.inherit</field>-->
|
||||
<!--<field name="model">project.project</field>-->
|
||||
<!--<field name="inherit_id" ref="project.edit_project" />-->
|
||||
<!--<field name="arch" type="xml">-->
|
||||
<!--<xpath expr="//notebook" position="inside">-->
|
||||
<!--<page name="note_page" string="Notes">-->
|
||||
<!--<field name="note" nolabel="1" type="html"/>-->
|
||||
<!--<div class="oe_clear"/>-->
|
||||
<!--</page>-->
|
||||
<!--</xpath>-->
|
||||
<!--</field>-->
|
||||
<!--</record>-->
|
||||
|
||||
<record id="view_task_form2_inherit" model="ir.ui.view">
|
||||
<field name="name">project.task.form.inherit</field>
|
||||
<field name="model">project.task</field>
|
||||
<field name="inherit_id" ref="project.view_task_form2" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='subtask_count']" position="replace">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_value"><field name="subtask_count_done" widget="statinfo" nolabel="1"/> / <field name="subtask_count" widget="statinfo" nolabel="1"/></span>
|
||||
<span class="o_stat_text">Sub-Tasks</span>
|
||||
</div>
|
||||
</xpath>
|
||||
<xpath expr="//page[@name='description_page']" position="after">
|
||||
<page name="task_lines" string="Todo List">
|
||||
<field name="line_ids" widget="one2many_list">
|
||||
<tree editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="kanban_state" widget="state_selection" string="Status"/>
|
||||
<field name="user_id" string="Completed by" options="{'no_create': True, 'no_create_edit': True, 'no_open': True}"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -6,6 +6,12 @@ from datetime import datetime, timedelta
|
||||
class TestPurchaseBySaleHistory(common.TransactionCase):
|
||||
|
||||
def test_00_wizard(self):
|
||||
wh1 = self.env.ref('stock.warehouse0')
|
||||
wh2 = self.env['stock.warehouse'].create({
|
||||
'name': 'WH2',
|
||||
'code': 'twh2',
|
||||
})
|
||||
|
||||
sale_partner = self.env.ref('base.res_partner_2')
|
||||
purchase_partner = self.env['res.partner'].create({
|
||||
'name': 'Purchase Partner',
|
||||
@@ -74,12 +80,13 @@ class TestPurchaseBySaleHistory(common.TransactionCase):
|
||||
'history_start': history_start,
|
||||
'history_end': history_end,
|
||||
'procure_days': days,
|
||||
'history_warehouse_ids': [(4, wh1.id, None)],
|
||||
})
|
||||
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 == product11).product_qty, 3.0 + 3.0) # 3 from Sales History, 3 from Demand (from the sale)
|
||||
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)
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product2).product_qty, 3.0 + 3.0)
|
||||
|
||||
# Make additional sales history...
|
||||
sale_date = fields.Datetime.to_string(datetime.now() - timedelta(days=15))
|
||||
@@ -96,9 +103,29 @@ class TestPurchaseBySaleHistory(common.TransactionCase):
|
||||
}).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 == product11).product_qty, 6.0 + 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)
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product2).product_qty, 6.0 + 6.0)
|
||||
|
||||
# Make additional sales history in other warehouse
|
||||
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',
|
||||
'warehouse_id': wh2.id,
|
||||
'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 + 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 + 6.0)
|
||||
|
||||
# Make additional sales history that should NOT be counted...
|
||||
sale_date = fields.Datetime.to_string(datetime.now() - timedelta(days=61))
|
||||
@@ -115,19 +142,39 @@ class TestPurchaseBySaleHistory(common.TransactionCase):
|
||||
}).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 == product11).product_qty, 6.0 + 9.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)
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product2).product_qty, 6.0 + 9.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 == product11).product_qty, 6.0 + 9.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)
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 3.0 + 9.0)
|
||||
|
||||
# Cause Inventory on existing product to make sure we don't order it.
|
||||
adjust_product11 = self.env['stock.inventory'].create({
|
||||
'name': 'Product11',
|
||||
'location_id': wh1.lot_stock_id.id,
|
||||
'product_id': product11.id,
|
||||
'filter': 'product',
|
||||
})
|
||||
adjust_product11.action_start()
|
||||
adjust_product11.line_ids.create({
|
||||
'inventory_id': adjust_product11.id,
|
||||
'product_id': product11.id,
|
||||
'product_qty': 100.0,
|
||||
'location_id': wh1.lot_stock_id.id,
|
||||
})
|
||||
adjust_product11.action_done()
|
||||
|
||||
wiz.action_confirm()
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 0.0) # Because we have so much in stock now.
|
||||
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)
|
||||
|
||||
@@ -16,6 +16,11 @@ class PurchaseBySaleHistory(models.TransientModel):
|
||||
'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.')
|
||||
history_warehouse_ids = fields.Many2many('stock.warehouse', string='Warehouses',
|
||||
help='Sales are calculated by these warehouses. '
|
||||
'Current Inventory is summed from these warehouses. '
|
||||
'If it is left blank then all warehouses and inventory '
|
||||
'will be considered.')
|
||||
|
||||
@api.multi
|
||||
@api.depends('history_start', 'history_end')
|
||||
@@ -54,18 +59,48 @@ class PurchaseBySaleHistory(models.TransientModel):
|
||||
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)))
|
||||
p_ids = tuple(product_ids)
|
||||
if self.history_warehouse_ids:
|
||||
wh_ids = tuple(self.history_warehouse_ids.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 AND warehouse_id IN %s
|
||||
GROUP BY 1""",
|
||||
(self.history_start, self.history_end, p_ids, wh_ids))
|
||||
else:
|
||||
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, p_ids))
|
||||
return self.env.cr.fetchall()
|
||||
|
||||
def _apply_history(self, history):
|
||||
def _apply_history_product(self, product, history):
|
||||
qty = ceil(history['sold_qty'] * self.procure_days / self.history_days)
|
||||
history['buy_qty'] = max((0.0, qty - product.virtual_available))
|
||||
|
||||
def _apply_history(self, history, product_ids):
|
||||
line_model = self.env['purchase.order.line']
|
||||
updated_lines = line_model.browse()
|
||||
for pid, sold_qty in history:
|
||||
|
||||
# Collect stock to consider against the sales demand.
|
||||
product_model = self.env['product.product']
|
||||
if self.history_warehouse_ids:
|
||||
product_model = self.env['product.product']\
|
||||
.with_context({'location': [wh.lot_stock_id.id for wh in self.history_warehouse_ids]})
|
||||
products = product_model.browse(product_ids)
|
||||
|
||||
#product_available_stock = {p.id: p.virtual_available for p in products}
|
||||
history_dict = {pid: {'sold_qty': sold_qty} for pid, sold_qty in history}
|
||||
for p in products:
|
||||
if p.id not in history_dict:
|
||||
history_dict[p.id] = {'sold_qty': 0.0}
|
||||
self._apply_history_product(p, history_dict[p.id])
|
||||
|
||||
for pid, history in history_dict.items():
|
||||
# TODO: Should convert from Sale UOM to Purchase UOM
|
||||
qty = ceil(sold_qty * self.procure_days / self.history_days)
|
||||
qty = history.get('buy_qty', 0.0)
|
||||
|
||||
# Find line that already exists on PO
|
||||
line = self.purchase_id.order_line.filtered(lambda l: l.product_id.id == pid)
|
||||
if line:
|
||||
@@ -95,6 +130,6 @@ class PurchaseBySaleHistory(models.TransientModel):
|
||||
self.ensure_one()
|
||||
history_product_ids = self._history_product_ids()
|
||||
history = self._sale_history(history_product_ids)
|
||||
self._apply_history(history)
|
||||
self._apply_history(history, history_product_ids)
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<field name="history_start"/>
|
||||
<field name="history_end"/>
|
||||
<field name="history_days"/>
|
||||
<field name="history_warehouse_ids" widget="many2many_tags"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="procure_days"/>
|
||||
|
||||
1
purchase_by_sale_history_mrp/__init__.py
Executable file
1
purchase_by_sale_history_mrp/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
from . import wizard
|
||||
20
purchase_by_sale_history_mrp/__manifest__.py
Executable file
20
purchase_by_sale_history_mrp/__manifest__.py
Executable file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
'name': 'Purchase by Sale History MRP',
|
||||
'author': 'Hibou Corp. <hello@hibou.io>',
|
||||
'version': '11.0.1.0.0',
|
||||
'category': 'Purchases',
|
||||
'sequence': 95,
|
||||
'summary': 'Buy Raw materials based on sales from Finished products.',
|
||||
'description': """
|
||||
Buy Raw materials based on sales from Finished products.
|
||||
""",
|
||||
'website': 'https://hibou.io/',
|
||||
'depends': [
|
||||
'purchase_by_sale_history',
|
||||
'mrp',
|
||||
],
|
||||
'data': [
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
}
|
||||
1
purchase_by_sale_history_mrp/tests/__init__.py
Normal file
1
purchase_by_sale_history_mrp/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_purchase_by_sale_history
|
||||
@@ -0,0 +1,203 @@
|
||||
from odoo.addons.purchase_by_sale_history.tests import test_purchase_by_sale_history
|
||||
from odoo import fields
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MTestPurchaseBySaleHistoryMRP(test_purchase_by_sale_history.TestPurchaseBySaleHistory):
|
||||
|
||||
def test_00_wizard(self):
|
||||
wh1 = self.env.ref('stock.warehouse0')
|
||||
wh2 = self.env['stock.warehouse'].create({
|
||||
'name': 'WH2',
|
||||
'code': 'twh2',
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
product_raw = self.env['product.product'].create({
|
||||
'name': 'Product Raw',
|
||||
'type': 'product',
|
||||
})
|
||||
_logger.warn('product_raw: ' + str(product_raw))
|
||||
|
||||
product11_bom = self.env['mrp.bom'].create({
|
||||
'product_tmpl_id': product11.product_tmpl_id.id,
|
||||
'product_qty': 1.0,
|
||||
'product_uom_id': product11.uom_id.id,
|
||||
'bom_line_ids': [(0, 0, {
|
||||
'product_id': product_raw.id,
|
||||
'product_qty': 2.0,
|
||||
'product_uom_id': product_raw.uom_id.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': product_raw.product_tmpl_id.id,
|
||||
'product_id': product_raw.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,
|
||||
'history_warehouse_ids': [(4, wh1.id, None)],
|
||||
})
|
||||
self.assertEqual(wiz.history_days, days)
|
||||
wiz.action_confirm()
|
||||
self.assertTrue(po1.order_line.filtered(lambda l: l.product_id == product_raw))
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product_raw).product_qty, 24.0) # x
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 0.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 + 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 == product_raw).product_qty, 48.0)
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 0.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 + 6.0)
|
||||
|
||||
# Make additional sales history in other warehouse
|
||||
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',
|
||||
'warehouse_id': wh2.id,
|
||||
'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 == product_raw).product_qty, 48.0)
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 0.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 + 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 == product_raw).product_qty, 60.0)
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 0.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 + 9.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()
|
||||
|
||||
# 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 == product_raw).product_qty, 48.0)
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 0.0)
|
||||
|
||||
# Cause Inventory on existing product to make sure we don't order it.
|
||||
adjust_product11 = self.env['stock.inventory'].create({
|
||||
'name': 'Product11',
|
||||
'location_id': wh1.lot_stock_id.id,
|
||||
'product_id': product11.id,
|
||||
'filter': 'product',
|
||||
})
|
||||
adjust_product11.action_start()
|
||||
adjust_product11.line_ids.create({
|
||||
'inventory_id': adjust_product11.id,
|
||||
'product_id': product11.id,
|
||||
'product_qty': 100.0,
|
||||
'location_id': wh1.lot_stock_id.id,
|
||||
})
|
||||
adjust_product11.action_done()
|
||||
|
||||
wiz.action_confirm()
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product_raw).product_qty, 24.0) # No longer have needs on product11 but we still have them for product12
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 0.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)
|
||||
1
purchase_by_sale_history_mrp/wizard/__init__.py
Normal file
1
purchase_by_sale_history_mrp/wizard/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import purchase_by_sale_history
|
||||
@@ -0,0 +1,60 @@
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class PurchaseBySaleHistory(models.TransientModel):
|
||||
_inherit = 'purchase.sale.history.make'
|
||||
|
||||
def _apply_history_product(self, product, history):
|
||||
# Override to recursively collect sales and forcast for products produced by this product.
|
||||
bom_line_model = self.env['mrp.bom.line']
|
||||
product_model = self.env['product.product']
|
||||
if self.history_warehouse_ids:
|
||||
product_model = self.env['product.product'] \
|
||||
.with_context({'location': [wh.lot_stock_id.id for wh in self.history_warehouse_ids]})
|
||||
visited_product_ids = set()
|
||||
|
||||
def bom_parent_product_ids(product):
|
||||
if product.id in visited_product_ids:
|
||||
# Cycle detected
|
||||
return 0.0
|
||||
visited_product_ids.add(product.id)
|
||||
|
||||
bom_lines = bom_line_model.search([('product_id', '=', product.id), ('bom_id.active', '=', True)])
|
||||
if not bom_lines:
|
||||
# Recursive Basecase
|
||||
return 0.0
|
||||
|
||||
product_ids = set()
|
||||
for line in bom_lines:
|
||||
product_ids |= bom_parent_product_ids_line(line)
|
||||
product_ids_dict = {}
|
||||
for pid, ratio in product_ids:
|
||||
if pid in product_ids_dict:
|
||||
raise UserError('You cannot have two identical finished goods being created from different ratios.')
|
||||
product_ids_dict[pid] = {'ratio': ratio}
|
||||
|
||||
history = self._sale_history(product_ids_dict.keys())
|
||||
products = product_model.browse(product_ids_dict.keys())
|
||||
|
||||
for pid, sold_qty in history:
|
||||
product_ids_dict[pid]['sold_qty'] = sold_qty
|
||||
for p in products:
|
||||
qty = product_ids_dict[p.id].get('sold_qty', 0.0) * self.procure_days / self.history_days
|
||||
product_ids_dict[p.id]['buy_qty'] = max((0.0, qty - p.virtual_available))
|
||||
product_ids_dict[p.id]['buy_qty'] += bom_parent_product_ids(p)
|
||||
product_ids_dict[p.id]['buy_qty'] *= product_ids_dict[p.id].get('ratio', 1.0)
|
||||
|
||||
return sum(vals['buy_qty'] for vals in product_ids_dict.values())
|
||||
|
||||
def bom_parent_product_ids_line(line):
|
||||
product_ids = set()
|
||||
if line.bom_id.product_id:
|
||||
product_ids.add((line.bom_id.product_id, line.product_qty))
|
||||
else:
|
||||
for p in line.bom_id.product_tmpl_id.product_variant_ids:
|
||||
product_ids.add((p.id, line.product_qty))
|
||||
return product_ids
|
||||
|
||||
super(PurchaseBySaleHistory, self)._apply_history_product(product, history)
|
||||
history['buy_qty'] += bom_parent_product_ids(product)
|
||||
@@ -0,0 +1,53 @@
|
||||
<?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"/>
|
||||
<field name="history_warehouse_ids" widget="many2many_tags"/>
|
||||
</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>
|
||||
1
purchase_exception
Symbolic link
1
purchase_exception
Symbolic link
@@ -0,0 +1 @@
|
||||
external/hibou-oca/purchase-workflow/purchase_exception
|
||||
1
purchase_minimum_amount
Symbolic link
1
purchase_minimum_amount
Symbolic link
@@ -0,0 +1 @@
|
||||
external/hibou-oca/purchase-workflow/purchase_minimum_amount
|
||||
1
purchase_order_approval_block
Symbolic link
1
purchase_order_approval_block
Symbolic link
@@ -0,0 +1 @@
|
||||
external/hibou-oca/purchase-workflow/purchase_order_approval_block
|
||||
@@ -9,5 +9,18 @@ class StockPicking(models.Model):
|
||||
def send_to_shipper(self):
|
||||
res = False
|
||||
for pick in self.filtered(lambda p: not p.carrier_tracking_ref):
|
||||
# deliver full order if no items are done.
|
||||
pick_has_no_done = sum(pick.move_line_ids.mapped('qty_done')) == 0
|
||||
if pick_has_no_done:
|
||||
pick._rma_complete()
|
||||
res = super(StockPicking, pick).send_to_shipper()
|
||||
if pick_has_no_done:
|
||||
pick._rma_complete_reverse()
|
||||
return res
|
||||
|
||||
def _rma_complete(self):
|
||||
for line in self.move_line_ids:
|
||||
line.qty_done = line.product_uom_qty
|
||||
|
||||
def _rma_complete_reverse(self):
|
||||
self.move_line_ids.write({'qty_done': 0.0})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user