From 4b64c8b72278f3d0a761779d54a02707b6c67473 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Tue, 5 Jun 2018 11:30:29 -0700 Subject: [PATCH 1/8] Initial commit of `product_catch_weight` for 11.0 --- product_catch_weight/__init__.py | 1 + product_catch_weight/__manifest__.py | 19 +++ product_catch_weight/models/__init__.py | 3 + .../models/account_invoice.py | 42 +++++ product_catch_weight/models/stock.py | 20 +++ product_catch_weight/models/stock_patch.py | 114 +++++++++++++ product_catch_weight/tests/__init__.py | 1 + .../tests/test_catch_weight.py | 152 ++++++++++++++++++ product_catch_weight/views/stock_views.xml | 35 ++++ 9 files changed, 387 insertions(+) create mode 100644 product_catch_weight/__init__.py create mode 100644 product_catch_weight/__manifest__.py create mode 100644 product_catch_weight/models/__init__.py create mode 100644 product_catch_weight/models/account_invoice.py create mode 100644 product_catch_weight/models/stock.py create mode 100644 product_catch_weight/models/stock_patch.py create mode 100644 product_catch_weight/tests/__init__.py create mode 100644 product_catch_weight/tests/test_catch_weight.py create mode 100644 product_catch_weight/views/stock_views.xml diff --git a/product_catch_weight/__init__.py b/product_catch_weight/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/product_catch_weight/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/product_catch_weight/__manifest__.py b/product_catch_weight/__manifest__.py new file mode 100644 index 00000000..7c2ef68c --- /dev/null +++ b/product_catch_weight/__manifest__.py @@ -0,0 +1,19 @@ +{ + '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/stock_views.xml', + ], + 'installable': True, + 'application': False, +} diff --git a/product_catch_weight/models/__init__.py b/product_catch_weight/models/__init__.py new file mode 100644 index 00000000..57a87093 --- /dev/null +++ b/product_catch_weight/models/__init__.py @@ -0,0 +1,3 @@ +from . import account_invoice +from . import stock_patch +from . import stock diff --git a/product_catch_weight/models/account_invoice.py b/product_catch_weight/models/account_invoice.py new file mode 100644 index 00000000..ab72943c --- /dev/null +++ b/product_catch_weight/models/account_invoice.py @@ -0,0 +1,42 @@ +from odoo import api, fields, models +import logging + +_logger = logging.getLogger(__name__) + + +class AccountInvoiceLine(models.Model): + _inherit = 'account.invoice.line' + + @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 + 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 + price = price * ratio + + 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 \ No newline at end of file diff --git a/product_catch_weight/models/stock.py b/product_catch_weight/models/stock.py new file mode 100644 index 00000000..6acec3a4 --- /dev/null +++ b/product_catch_weight/models/stock.py @@ -0,0 +1,20 @@ +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), default=1.0) + + +class StockMoveLine(models.Model): + _inherit = 'stock.move.line' + + lot_catch_weight_ratio = fields.Float(string='Catch Weight Ratio', digits=(10, 6), default=1.0) + lot_catch_weight_ratio_related = fields.Float(related='lot_id.catch_weight_ratio') + #lot_catch_weight_ratio = fields.Float(related='lot_id.catch_weight_ratio') + + # def _action_done(self): + # super(StockMoveLine, self)._action_done() + # for ml in self.filtered(lambda l: l.product_id.tracking == 'serial' and l.lot_id): + # ml.lot_id.catch_weight_ratio = ml.lot_catch_weight_ratio diff --git a/product_catch_weight/models/stock_patch.py b/product_catch_weight/models/stock_patch.py new file mode 100644 index 00000000..8787f4f6 --- /dev/null +++ b/product_catch_weight/models/stock_patch.py @@ -0,0 +1,114 @@ +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 = self.env['stock.production.lot'].create( + {'name': ml.lot_name, 'product_id': ml.product_id.id, 'catch_weight_ratio': ml.lot_catch_weight_ratio} + ) + 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 diff --git a/product_catch_weight/tests/__init__.py b/product_catch_weight/tests/__init__.py new file mode 100644 index 00000000..0edad729 --- /dev/null +++ b/product_catch_weight/tests/__init__.py @@ -0,0 +1 @@ +from . import test_catch_weight diff --git a/product_catch_weight/tests/test_catch_weight.py b/product_catch_weight/tests/test_catch_weight.py new file mode 100644 index 00000000..a167e530 --- /dev/null +++ b/product_catch_weight/tests/test_catch_weight.py @@ -0,0 +1,152 @@ +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.partner1 = self.env.ref('base.res_partner_2') + 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)], + }) + #self.product1 = self.env.ref('product.product_order_01') + self.product1.write({ + 'type': 'product', + 'tracking': 'serial', + }) + self.stock_location = self.env.ref('stock.stock_location_stock') + + + # 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): + ratio = 0.8 + lot = self.env['stock.production.lot'].create({ + 'product_id': self.product1.id, + 'name': '123456789', + 'catch_weight_ratio': ratio, + }) + 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.assertEqual(inv.amount_total, ratio * self.product1.list_price) + + def test_so_invoice2(self): + ratio1 = 0.8 + ratio2 = 1.1 + lot1 = self.env['stock.production.lot'].create({ + 'product_id': self.product1.id, + 'name': '1-low', + 'catch_weight_ratio': ratio1, + }) + lot2 = self.env['stock.production.lot'].create({ + 'product_id': self.product1.id, + 'name': '1-high', + 'catch_weight_ratio': ratio2, + }) + 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.assertEqual(inv.amount_total, (ratio1 * self.product1.list_price) + (ratio2 * self.product1.list_price)) + + def test_po_invoice(self): + ratio1 = 0.8 + ratio2 = 1.1 + ratios = (ratio1, ratio2) + 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, 'lot_catch_weight_ratio': ratios[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.assertEqual(inv.amount_total, (ratio1 * price) + (ratio2 * price)) + + diff --git a/product_catch_weight/views/stock_views.xml b/product_catch_weight/views/stock_views.xml new file mode 100644 index 00000000..cee50753 --- /dev/null +++ b/product_catch_weight/views/stock_views.xml @@ -0,0 +1,35 @@ + + + + stock.production.lot.form.inherit + stock.production.lot + + + + + + + + + + stock.move.line.form.inherit + stock.move.line + + + + + + + + + stock.move.line.operations.tree.inherit + stock.move.line + + + + + + + + + \ No newline at end of file From 7ae88479d2dfa9946529b890a7e98650245bb567 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Sun, 10 Jun 2018 11:02:27 -0700 Subject: [PATCH 2/8] Improve UI, allowing user to measure the catch weight in a specific unit of measure. This UOM should be convertable (in the same category) as the normal stock UOM for the product. Example: If you want to sell 'units' of 50lbs then you should make a "50lbs" UOM in the Weight category and use that as the sale and purchase UOM, then your "Catch Weight UOM" can be the stock "lb(s)" UOM. --- product_catch_weight/__manifest__.py | 1 + product_catch_weight/models/__init__.py | 1 + .../models/account_invoice.py | 6 ++ product_catch_weight/models/product.py | 7 ++ product_catch_weight/models/stock.py | 44 +++++++++--- product_catch_weight/models/stock_patch.py | 3 +- .../tests/test_catch_weight.py | 46 +++++++------ .../views/account_invoice_views.xml | 69 +++++++++++++++++++ product_catch_weight/views/stock_views.xml | 43 +++++++++--- 9 files changed, 182 insertions(+), 38 deletions(-) create mode 100644 product_catch_weight/models/product.py create mode 100644 product_catch_weight/views/account_invoice_views.xml diff --git a/product_catch_weight/__manifest__.py b/product_catch_weight/__manifest__.py index 7c2ef68c..76d3cea2 100644 --- a/product_catch_weight/__manifest__.py +++ b/product_catch_weight/__manifest__.py @@ -12,6 +12,7 @@ 'license': 'AGPL-3', 'website': 'https://hibou.io/', 'data': [ + 'views/account_invoice_views.xml', 'views/stock_views.xml', ], 'installable': True, diff --git a/product_catch_weight/models/__init__.py b/product_catch_weight/models/__init__.py index 57a87093..5e099bc5 100644 --- a/product_catch_weight/models/__init__.py +++ b/product_catch_weight/models/__init__.py @@ -1,3 +1,4 @@ from . import account_invoice +from . import product from . import stock_patch from . import stock diff --git a/product_catch_weight/models/account_invoice.py b/product_catch_weight/models/account_invoice.py index ab72943c..ee8c9f92 100644 --- a/product_catch_weight/models/account_invoice.py +++ b/product_catch_weight/models/account_invoice.py @@ -7,6 +7,9 @@ _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', @@ -17,6 +20,7 @@ class AccountInvoiceLine(models.Model): 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: @@ -26,7 +30,9 @@ class AccountInvoiceLine(models.Model): 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: diff --git a/product_catch_weight/models/product.py b/product_catch_weight/models/product.py new file mode 100644 index 00000000..16cb4b91 --- /dev/null +++ b/product_catch_weight/models/product.py @@ -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') diff --git a/product_catch_weight/models/stock.py b/product_catch_weight/models/stock.py index 6acec3a4..1ad5d3b6 100644 --- a/product_catch_weight/models/stock.py +++ b/product_catch_weight/models/stock.py @@ -4,17 +4,43 @@ 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), default=1.0) + 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' - lot_catch_weight_ratio = fields.Float(string='Catch Weight Ratio', digits=(10, 6), default=1.0) - lot_catch_weight_ratio_related = fields.Float(related='lot_id.catch_weight_ratio') - #lot_catch_weight_ratio = fields.Float(related='lot_id.catch_weight_ratio') - - # def _action_done(self): - # super(StockMoveLine, self)._action_done() - # for ml in self.filtered(lambda l: l.product_id.tracking == 'serial' and l.lot_id): - # ml.lot_id.catch_weight_ratio = ml.lot_catch_weight_ratio + 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') diff --git a/product_catch_weight/models/stock_patch.py b/product_catch_weight/models/stock_patch.py index 8787f4f6..fca04e4f 100644 --- a/product_catch_weight/models/stock_patch.py +++ b/product_catch_weight/models/stock_patch.py @@ -41,8 +41,9 @@ def _action_done(self): # 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_ratio': ml.lot_catch_weight_ratio} + {'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: diff --git a/product_catch_weight/tests/test_catch_weight.py b/product_catch_weight/tests/test_catch_weight.py index a167e530..1b13cb55 100644 --- a/product_catch_weight/tests/test_catch_weight.py +++ b/product_catch_weight/tests/test_catch_weight.py @@ -9,7 +9,16 @@ _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', @@ -17,13 +26,10 @@ class TestPicking(TransactionCase): '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, }) - #self.product1 = self.env.ref('product.product_order_01') - self.product1.write({ - 'type': 'product', - 'tracking': 'serial', - }) - self.stock_location = self.env.ref('stock.stock_location_stock') # def test_creation(self): @@ -50,12 +56,13 @@ class TestPicking(TransactionCase): # self.env['stock.quant']._update_available_quantity(self.productA, stock_location, 1.0, lot_id=lot) def test_so_invoice(self): - ratio = 0.8 + ref_weight = 45.0 lot = self.env['stock.production.lot'].create({ 'product_id': self.product1.id, 'name': '123456789', - 'catch_weight_ratio': ratio, + '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, @@ -75,20 +82,20 @@ class TestPicking(TransactionCase): inv_id = so.action_invoice_create() inv = self.env['account.invoice'].browse(inv_id) - self.assertEqual(inv.amount_total, ratio * self.product1.list_price) + self.assertAlmostEqual(inv.amount_total, lot.catch_weight_ratio * self.product1.list_price) def test_so_invoice2(self): - ratio1 = 0.8 - ratio2 = 1.1 + 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_ratio': ratio1, + 'catch_weight': ref_weight1, }) lot2 = self.env['stock.production.lot'].create({ 'product_id': self.product1.id, 'name': '1-high', - 'catch_weight_ratio': ratio2, + '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) @@ -111,12 +118,12 @@ class TestPicking(TransactionCase): inv_id = so.action_invoice_create() inv = self.env['account.invoice'].browse(inv_id) - self.assertEqual(inv.amount_total, (ratio1 * self.product1.list_price) + (ratio2 * self.product1.list_price)) + self.assertAlmostEqual(inv.amount_total, self.product1.list_price * (lot1.catch_weight_ratio + lot2.catch_weight_ratio)) def test_po_invoice(self): - ratio1 = 0.8 - ratio2 = 1.1 - ratios = (ratio1, ratio2) + 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, @@ -135,7 +142,7 @@ class TestPicking(TransactionCase): 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, 'lot_catch_weight_ratio': ratios[i]}) + line.write({'lot_name': str(i), 'qty_done': 1.0, 'catch_weight': weights[i]}) picking.button_validate() self.assertEqual(picking.state, 'done') @@ -147,6 +154,5 @@ class TestPicking(TransactionCase): inv.purchase_order_change() self.assertEqual(len(inv.invoice_line_ids), 1) self.assertEqual(inv.invoice_line_ids.quantity, 2.0) - self.assertEqual(inv.amount_total, (ratio1 * price) + (ratio2 * price)) - + self.assertAlmostEqual(inv.amount_total, price * sum(w / self.nominal_weight for w in weights)) diff --git a/product_catch_weight/views/account_invoice_views.xml b/product_catch_weight/views/account_invoice_views.xml new file mode 100644 index 00000000..8eb57740 --- /dev/null +++ b/product_catch_weight/views/account_invoice_views.xml @@ -0,0 +1,69 @@ + + + + account.invoice.form.inherit + account.invoice + + + + + + + + + + account.invoice.supplier.form.inherit + account.invoice + + + + + + + + + + + + \ No newline at end of file diff --git a/product_catch_weight/views/stock_views.xml b/product_catch_weight/views/stock_views.xml index cee50753..40e2d9dd 100644 --- a/product_catch_weight/views/stock_views.xml +++ b/product_catch_weight/views/stock_views.xml @@ -7,17 +7,32 @@ + + - - stock.move.line.form.inherit - stock.move.line - + + + + + + + + + + + + stock.move.operations.form.inherit + stock.move + - - + + + + + {'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} @@ -27,8 +42,20 @@ - - + + + + + + + + + product.template.common.form.inherit + product.template + + + + From a248cc102abf51bff79d6e152ea2d8ffae3a0f0c Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Thu, 6 Sep 2018 14:46:28 -0700 Subject: [PATCH 3/8] Add several OCA modules that we have tested and used. --- .gitmodules | 6 ++++++ auditlog | 1 + connector_magento_product_by_sku | 1 + external/hibou-oca/purchase-workflow | 1 + external/hibou-oca/stock-logistics-workflow | 1 + purchase_exception | 1 + purchase_minimum_amount | 1 + purchase_order_approval_block | 1 + stock_split_picking | 1 + 9 files changed, 14 insertions(+) create mode 120000 auditlog create mode 120000 connector_magento_product_by_sku create mode 160000 external/hibou-oca/purchase-workflow create mode 160000 external/hibou-oca/stock-logistics-workflow create mode 120000 purchase_exception create mode 120000 purchase_minimum_amount create mode 120000 purchase_order_approval_block create mode 120000 stock_split_picking diff --git a/.gitmodules b/.gitmodules index e3c0dec6..ed7e540e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -29,3 +29,9 @@ [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 diff --git a/auditlog b/auditlog new file mode 120000 index 00000000..7d9f602f --- /dev/null +++ b/auditlog @@ -0,0 +1 @@ +external/hibou-oca/server-tools/auditlog \ No newline at end of file diff --git a/connector_magento_product_by_sku b/connector_magento_product_by_sku new file mode 120000 index 00000000..1b7f87bd --- /dev/null +++ b/connector_magento_product_by_sku @@ -0,0 +1 @@ +external/hibou-oca/connector-magento/connector_magento_product_by_sku \ No newline at end of file diff --git a/external/hibou-oca/purchase-workflow b/external/hibou-oca/purchase-workflow new file mode 160000 index 00000000..5973bb87 --- /dev/null +++ b/external/hibou-oca/purchase-workflow @@ -0,0 +1 @@ +Subproject commit 5973bb878baba5656d08067079e07edd1ec6accc diff --git a/external/hibou-oca/stock-logistics-workflow b/external/hibou-oca/stock-logistics-workflow new file mode 160000 index 00000000..cc0b36e7 --- /dev/null +++ b/external/hibou-oca/stock-logistics-workflow @@ -0,0 +1 @@ +Subproject commit cc0b36e76c9ac0e508ef69edbc5572aaa72030e0 diff --git a/purchase_exception b/purchase_exception new file mode 120000 index 00000000..b0e39c0b --- /dev/null +++ b/purchase_exception @@ -0,0 +1 @@ +external/hibou-oca/purchase-workflow/purchase_exception \ No newline at end of file diff --git a/purchase_minimum_amount b/purchase_minimum_amount new file mode 120000 index 00000000..2a4e4214 --- /dev/null +++ b/purchase_minimum_amount @@ -0,0 +1 @@ +external/hibou-oca/purchase-workflow/purchase_minimum_amount \ No newline at end of file diff --git a/purchase_order_approval_block b/purchase_order_approval_block new file mode 120000 index 00000000..85ef0317 --- /dev/null +++ b/purchase_order_approval_block @@ -0,0 +1 @@ +external/hibou-oca/purchase-workflow/purchase_order_approval_block \ No newline at end of file diff --git a/stock_split_picking b/stock_split_picking new file mode 120000 index 00000000..45277b6c --- /dev/null +++ b/stock_split_picking @@ -0,0 +1 @@ +external/hibou-oca/stock-logistics-workflow/stock_split_picking \ No newline at end of file From 02319947bc8712bb31f0fa0bbb476af96f970ee4 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Thu, 6 Sep 2018 14:44:19 -0700 Subject: [PATCH 4/8] Initial commit of `delivery_stamps` for 10.0 --- delivery_stamps/__init__.py | 2 + delivery_stamps/__manifest__.py | 52 + delivery_stamps/models/__init__.py | 2 + delivery_stamps/models/api/LICENSE | 31 + delivery_stamps/models/api/__init__.py | 14 + delivery_stamps/models/api/config.py | 102 + delivery_stamps/models/api/services.py | 298 ++ delivery_stamps/models/api/tests.py | 149 + .../models/api/wsdls/stamps_v49.test.wsdl | 3381 +++++++++++++++++ .../models/api/wsdls/stamps_v49.wsdl | 3381 +++++++++++++++++ delivery_stamps/models/delivery_stamps.py | 298 ++ .../views/delivery_stamps_view.xml | 28 + 12 files changed, 7738 insertions(+) create mode 100644 delivery_stamps/__init__.py create mode 100644 delivery_stamps/__manifest__.py create mode 100644 delivery_stamps/models/__init__.py create mode 100755 delivery_stamps/models/api/LICENSE create mode 100755 delivery_stamps/models/api/__init__.py create mode 100755 delivery_stamps/models/api/config.py create mode 100755 delivery_stamps/models/api/services.py create mode 100755 delivery_stamps/models/api/tests.py create mode 100755 delivery_stamps/models/api/wsdls/stamps_v49.test.wsdl create mode 100755 delivery_stamps/models/api/wsdls/stamps_v49.wsdl create mode 100644 delivery_stamps/models/delivery_stamps.py create mode 100644 delivery_stamps/views/delivery_stamps_view.xml diff --git a/delivery_stamps/__init__.py b/delivery_stamps/__init__.py new file mode 100644 index 00000000..89d26e2f --- /dev/null +++ b/delivery_stamps/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +import models diff --git a/delivery_stamps/__manifest__.py b/delivery_stamps/__manifest__.py new file mode 100644 index 00000000..1aa584ae --- /dev/null +++ b/delivery_stamps/__manifest__.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# +# +# Author: Jared Kipe +# Copyright 2017 Hibou Corp. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# + +{ + 'name': 'Stamps.com (USPS) Shipping', + 'summary': 'Send your shippings through Stamps.com and track them online.', + 'version': '10.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. + +Contributors +------------ + +* Jared Kipe + +""", + 'depends': [ + 'delivery', + ], + 'demo': [], + 'data': [ + 'views/delivery_stamps_view.xml', + ], + 'auto_install': False, + 'installable': True, +} diff --git a/delivery_stamps/models/__init__.py b/delivery_stamps/models/__init__.py new file mode 100644 index 00000000..c58180b1 --- /dev/null +++ b/delivery_stamps/models/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import delivery_stamps diff --git a/delivery_stamps/models/api/LICENSE b/delivery_stamps/models/api/LICENSE new file mode 100755 index 00000000..7276b0b2 --- /dev/null +++ b/delivery_stamps/models/api/LICENSE @@ -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. diff --git a/delivery_stamps/models/api/__init__.py b/delivery_stamps/models/api/__init__.py new file mode 100755 index 00000000..0654679c --- /dev/null +++ b/delivery_stamps/models/api/__init__.py @@ -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" diff --git a/delivery_stamps/models/api/config.py b/delivery_stamps/models/api/config.py new file mode 100755 index 00000000..09fd27ec --- /dev/null +++ b/delivery_stamps/models/api/config.py @@ -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 import pathname2url +from urlparse 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 diff --git a/delivery_stamps/models/api/services.py b/delivery_stamps/models/api/services.py new file mode 100755 index 00000000..50b313a3 --- /dev/null +++ b/delivery_stamps/models/api/services.py @@ -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, basestring) 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 diff --git a/delivery_stamps/models/api/tests.py b/delivery_stamps/models/api/tests.py new file mode 100755 index 00000000..6a306ccd --- /dev/null +++ b/delivery_stamps/models/api/tests.py @@ -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 diff --git a/delivery_stamps/models/api/wsdls/stamps_v49.test.wsdl b/delivery_stamps/models/api/wsdls/stamps_v49.test.wsdl new file mode 100755 index 00000000..7fde531e --- /dev/null +++ b/delivery_stamps/models/api/wsdls/stamps_v49.test.wsdl @@ -0,0 +1,3381 @@ + + + Stamps.com Web Services for Individual Meters (SWS/IM) Version 49 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Get list of shipments. + + + + + Set CodeWord information + + + + + Register a new Stamps.com account. + + + + + Generate an envelope indicium. + + + + + Generate an indicium. + + + + + Generate an unfunded indicium. + + + + + Generate a mailing label sheet. + + + + + Generate NetStamps indicia. + + + + + Calculate a rate or a list of rates. + + + + + Request carrier pickup from USPS. + + + + + Change Plan. + + + + + Set auto-buy settings + + + + + Generate a SCAN form. + + + + + Return the list of available CodeWord types. + + + + + Cleanse an address. + + + + + Get list of shipments. + + + + + Get URL for a Stamps.com web page. + + + + + Recover Username. + + + + + Get list of supported countries. + + + + + Change Password. + + + + + Price Store Orders. + + + + + Place Store Orders. + + + + + Get NetStamps Images. + + + + + Get status of plan change. + + + + + Purchase additional postage. + + + + + Resubmit Purchase. + + + + + Get list of NetStamps layouts. + + + + + Get list of cost codes. + + + + + Authenticate with transfer authenticator. + + + + + Cancel a previously issued indicium. + + + + + Start a password reset by sending a temporary password to the e-mail address on file. + + + + + Finish a password reset, setting the permanent password to a new password. + + + + + Retrieve codeword questions for user for starting password reset. + + + + + Void an unfunded indicium. + + + + + Fund an unfunded indicium. + + + + + Initial authentication. + + + + + Get account information, including postage balance. + + + + + Get status of postage purchase. + + + + + Get tracking events for shipment. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Stamps.com Web Services for Individual Meters (SWS/IM) Version 49 + + + + + + + + diff --git a/delivery_stamps/models/api/wsdls/stamps_v49.wsdl b/delivery_stamps/models/api/wsdls/stamps_v49.wsdl new file mode 100755 index 00000000..d8948f7e --- /dev/null +++ b/delivery_stamps/models/api/wsdls/stamps_v49.wsdl @@ -0,0 +1,3381 @@ + + + Stamps.com Web Services for Individual Meters (SWS/IM) Version 49 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Get list of shipments. + + + + + Set CodeWord information + + + + + Register a new Stamps.com account. + + + + + Generate an envelope indicium. + + + + + Generate an indicium. + + + + + Generate an unfunded indicium. + + + + + Generate a mailing label sheet. + + + + + Generate NetStamps indicia. + + + + + Calculate a rate or a list of rates. + + + + + Request carrier pickup from USPS. + + + + + Change Plan. + + + + + Set auto-buy settings + + + + + Generate a SCAN form. + + + + + Return the list of available CodeWord types. + + + + + Cleanse an address. + + + + + Get list of shipments. + + + + + Get URL for a Stamps.com web page. + + + + + Recover Username. + + + + + Get list of supported countries. + + + + + Change Password. + + + + + Price Store Orders. + + + + + Place Store Orders. + + + + + Get NetStamps Images. + + + + + Get status of plan change. + + + + + Purchase additional postage. + + + + + Resubmit Purchase. + + + + + Get list of NetStamps layouts. + + + + + Get list of cost codes. + + + + + Authenticate with transfer authenticator. + + + + + Cancel a previously issued indicium. + + + + + Start a password reset by sending a temporary password to the e-mail address on file. + + + + + Finish a password reset, setting the permanent password to a new password. + + + + + Retrieve codeword questions for user for starting password reset. + + + + + Void an unfunded indicium. + + + + + Fund an unfunded indicium. + + + + + Initial authentication. + + + + + Get account information, including postage balance. + + + + + Get status of postage purchase. + + + + + Get tracking events for shipment. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Stamps.com Web Services for Individual Meters (SWS/IM) Version 49 + + + + + + + + diff --git a/delivery_stamps/models/delivery_stamps.py b/delivery_stamps/models/delivery_stamps.py new file mode 100644 index 00000000..61f68dbb --- /dev/null +++ b/delivery_stamps/models/delivery_stamps.py @@ -0,0 +1,298 @@ +# -*- coding: utf-8 -*- +from datetime import date +from logging import getLogger +import urllib2 +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')]) + + +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 _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.group_id and picking.group_id.procurement_ids and picking.group_id.procurement_ids[0].sale_line_id: + return picking.group_id.procurement_ids[0].sale_line_id.order_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.project_id and order.project_id.subcompany_id: + company = order.project_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) + + 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.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) + + 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.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: + # has product with usps_exclude + if sum(1 for l in order.order_line if l.product_id.usps_exclude): + res.append(None) + continue + + 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_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
Tracking Number :
' + label.TrackingNumber + '
' + tracking_numbers.append(label.TrackingNumber) + carrier_price += float(label.Rate.Amount) + url = label.URL + + response = urllib2.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) diff --git a/delivery_stamps/views/delivery_stamps_view.xml b/delivery_stamps/views/delivery_stamps_view.xml new file mode 100644 index 00000000..bb1d07f2 --- /dev/null +++ b/delivery_stamps/views/delivery_stamps_view.xml @@ -0,0 +1,28 @@ + + + + + delivery.carrier.form.provider.stamps + delivery.carrier + + + + + + + + + + + + + + + + + + + + + + From 735e0e896d9f58bb98921ce1463f411cfd5289fd Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Thu, 6 Sep 2018 15:32:08 -0700 Subject: [PATCH 5/8] Modifications needed for Odoo 11.0 and Python3 --- delivery_stamps/__init__.py | 3 +-- delivery_stamps/__manifest__.py | 28 +---------------------- delivery_stamps/models/__init__.py | 1 - delivery_stamps/models/api/config.py | 6 ++--- delivery_stamps/models/api/services.py | 2 +- delivery_stamps/models/delivery_stamps.py | 17 +++++++------- 6 files changed, 14 insertions(+), 43 deletions(-) diff --git a/delivery_stamps/__init__.py b/delivery_stamps/__init__.py index 89d26e2f..0650744f 100644 --- a/delivery_stamps/__init__.py +++ b/delivery_stamps/__init__.py @@ -1,2 +1 @@ -# -*- coding: utf-8 -*- -import models +from . import models diff --git a/delivery_stamps/__manifest__.py b/delivery_stamps/__manifest__.py index 1aa584ae..6a16401d 100644 --- a/delivery_stamps/__manifest__.py +++ b/delivery_stamps/__manifest__.py @@ -1,28 +1,7 @@ -# -*- coding: utf-8 -*- -# -# -# Author: Jared Kipe -# Copyright 2017 Hibou Corp. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -# -# - { 'name': 'Stamps.com (USPS) Shipping', 'summary': 'Send your shippings through Stamps.com and track them online.', - 'version': '10.0.1.0.0', + 'version': '11.0.1.0.0', 'author': "Hibou Corp.", 'category': 'Warehouse', 'license': 'AGPL-3', @@ -34,11 +13,6 @@ Stamps.com (USPS) Shipping Send your shippings through Stamps.com and track them online. -Contributors ------------- - -* Jared Kipe - """, 'depends': [ 'delivery', diff --git a/delivery_stamps/models/__init__.py b/delivery_stamps/models/__init__.py index c58180b1..d675855d 100644 --- a/delivery_stamps/models/__init__.py +++ b/delivery_stamps/models/__init__.py @@ -1,2 +1 @@ -# -*- coding: utf-8 -*- from . import delivery_stamps diff --git a/delivery_stamps/models/api/config.py b/delivery_stamps/models/api/config.py index 09fd27ec..4665780f 100755 --- a/delivery_stamps/models/api/config.py +++ b/delivery_stamps/models/api/config.py @@ -9,9 +9,9 @@ :license: BSD, see LICENSE for more details. """ -from ConfigParser import NoOptionError, NoSectionError, SafeConfigParser -from urllib import pathname2url -from urlparse import urljoin +from configparser import NoOptionError, NoSectionError, SafeConfigParser +from urllib.request import pathname2url +from urllib.parse import urljoin import os diff --git a/delivery_stamps/models/api/services.py b/delivery_stamps/models/api/services.py index 50b313a3..1f7b350a 100755 --- a/delivery_stamps/models/api/services.py +++ b/delivery_stamps/models/api/services.py @@ -285,7 +285,7 @@ class XDecimal(XBuiltin): value for python. """ if topython: - if isinstance(value, basestring) and len(value): + if isinstance(value, str) and len(value): ret_val = Decimal(value) else: ret_val = None diff --git a/delivery_stamps/models/delivery_stamps.py b/delivery_stamps/models/delivery_stamps.py index 61f68dbb..9965320d 100644 --- a/delivery_stamps/models/delivery_stamps.py +++ b/delivery_stamps/models/delivery_stamps.py @@ -1,14 +1,13 @@ -# -*- coding: utf-8 -*- from datetime import date from logging import getLogger -import urllib2 +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 +from .api.config import StampsConfiguration +from .api.services import StampsService _logger = getLogger(__name__) @@ -105,16 +104,16 @@ class ProviderStamps(models.Model): return ret_val def _get_order_for_picking(self, picking): - if picking.group_id and picking.group_id.procurement_ids and picking.group_id.procurement_ids[0].sale_line_id: - return picking.group_id.procurement_ids[0].sale_line_id.order_id + 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.project_id and order.project_id.subcompany_id: - company = order.project_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): @@ -273,7 +272,7 @@ class ProviderStamps(models.Model): carrier_price += float(label.Rate.Amount) url = label.URL - response = urllib2.urlopen(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)} From a1aa1821a469764f12ec01bd992bbc999e24d3a5 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Tue, 11 Sep 2018 17:35:43 -0700 Subject: [PATCH 6/8] Initial commit of `mrp_production_add` for 11.0 --- mrp_production_add/README.rst | 26 ++++++++++ mrp_production_add/__init__.py | 1 + mrp_production_add/__manifest__.py | 17 +++++++ mrp_production_add/views/mrp_production.xml | 21 ++++++++ mrp_production_add/wizard/__init__.py | 1 + mrp_production_add/wizard/additem_wizard.py | 49 +++++++++++++++++++ .../wizard/additem_wizard_view.xml | 38 ++++++++++++++ 7 files changed, 153 insertions(+) create mode 100755 mrp_production_add/README.rst create mode 100755 mrp_production_add/__init__.py create mode 100755 mrp_production_add/__manifest__.py create mode 100755 mrp_production_add/views/mrp_production.xml create mode 100755 mrp_production_add/wizard/__init__.py create mode 100755 mrp_production_add/wizard/additem_wizard.py create mode 100755 mrp_production_add/wizard/additem_wizard_view.xml diff --git a/mrp_production_add/README.rst b/mrp_production_add/README.rst new file mode 100755 index 00000000..7032b4b6 --- /dev/null +++ b/mrp_production_add/README.rst @@ -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 `_. + +.. 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 `_. + +Copyright Hibou Corp. 2016. \ No newline at end of file diff --git a/mrp_production_add/__init__.py b/mrp_production_add/__init__.py new file mode 100755 index 00000000..40272379 --- /dev/null +++ b/mrp_production_add/__init__.py @@ -0,0 +1 @@ +from . import wizard diff --git a/mrp_production_add/__manifest__.py b/mrp_production_add/__manifest__.py new file mode 100755 index 00000000..0cd07a33 --- /dev/null +++ b/mrp_production_add/__manifest__.py @@ -0,0 +1,17 @@ +{ + 'name': 'MRP Production Add Item', + 'author': 'Hibou Corp. ', + '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, +} diff --git a/mrp_production_add/views/mrp_production.xml b/mrp_production_add/views/mrp_production.xml new file mode 100755 index 00000000..73c4aea1 --- /dev/null +++ b/mrp_production_add/views/mrp_production.xml @@ -0,0 +1,21 @@ + + + + + mrp.production.add_production_item.form.view + mrp.production + + + + +