From 0d2d2469c5af960a6b59452abb26ce9eaddf9c66 Mon Sep 17 00:00:00 2001 From: clementmbr Date: Tue, 5 Mar 2024 18:58:57 -0300 Subject: [PATCH] [IMP] basic sale_import_amazon OK --- requirements.txt | 1 + sale_import_amazon/__manifest__.py | 12 +- sale_import_amazon/data/amazon_cron.xml | 19 +++ .../data/amazon_marketplace.xml | 117 ++++++++++++++++++ .../models/amazon_marketplace.py | 6 +- sale_import_amazon/models/queue_job_chunk.py | 2 +- sale_import_amazon/models/sale_channel.py | 40 ++++-- .../models/sale_channel_importer_amazon.py | 39 ++++-- sale_import_amazon/models/sale_order.py | 6 +- sale_import_amazon/models/schemas.py | 3 +- .../security/ir.model.access.csv | 2 +- sale_import_amazon/tests/data.py | 2 +- .../tests/test_sale_import_amazon.py | 49 ++++---- sale_import_amazon/utils.py | 5 +- .../views/amazon_marketplace.xml | 31 ++--- sale_import_amazon/views/sale_channel.xml | 30 ++++- sale_import_amazon/views/sale_order.xml | 14 ++- sale_import_base/models/sale_channel.py | 2 + .../models/sale_channel_importer.py | 16 +++ sale_import_base/models/schemas.py | 3 + sale_import_base/views/sale_channel_view.xml | 42 ++++--- .../odoo/addons/sale_import_amazon | 1 + setup/sale_import_amazon/setup.py | 6 + 23 files changed, 345 insertions(+), 103 deletions(-) create mode 100644 sale_import_amazon/data/amazon_cron.xml create mode 100644 sale_import_amazon/data/amazon_marketplace.xml create mode 120000 setup/sale_import_amazon/odoo/addons/sale_import_amazon create mode 100644 setup/sale_import_amazon/setup.py diff --git a/requirements.txt b/requirements.txt index 56c3281b..30facce8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ # generated from manifests external_dependencies extendable_pydantic fastapi +python-amazon-sp-api diff --git a/sale_import_amazon/__manifest__.py b/sale_import_amazon/__manifest__.py index 9ac9e31c..48572858 100644 --- a/sale_import_amazon/__manifest__.py +++ b/sale_import_amazon/__manifest__.py @@ -7,16 +7,18 @@ "version": "16.0.1.0.0", "license": "AGPL-3", "author": "Akretion", - "website": "http://akretion.com", + "website": "https://github.com/akretion/sale-import", "depends": [ - "sale_stock", + "stock", # https://github.com/akretion/sale-import/ "sale_import_base", ], "data": [ - # 'views/amazon_marketplace.xml', - # 'views/sale_channel.xml', - # "views/sale_order.xml", + "views/amazon_marketplace.xml", + "views/sale_channel.xml", + "views/sale_order.xml", + "data/amazon_marketplace.xml", + "data/amazon_cron.xml", "security/ir.model.access.csv", ], "demo": [], diff --git a/sale_import_amazon/data/amazon_cron.xml b/sale_import_amazon/data/amazon_cron.xml new file mode 100644 index 00000000..5fb29504 --- /dev/null +++ b/sale_import_amazon/data/amazon_cron.xml @@ -0,0 +1,19 @@ + + + + + Amazon: import orders + + code + model.amazon_import_orders_chunk_cron() + + 60 + minutes + -1 + + + 1000 + + + \ No newline at end of file diff --git a/sale_import_amazon/data/amazon_marketplace.xml b/sale_import_amazon/data/amazon_marketplace.xml new file mode 100644 index 00000000..e19c0c51 --- /dev/null +++ b/sale_import_amazon/data/amazon_marketplace.xml @@ -0,0 +1,117 @@ + + + + + + + + + Amazon.com.br + A2Q3Y263D00KWC + BR + + + + Amazon.ca + A2EUQ1WTGCTBG2 + CA + + + Amazon.com.mx + A1AM78C64UM0Y8 + MX + + + Amazon.com + ATVPDKIKX0DER + US + + + + Amazon.ae + A2VIGQ35RCS4UG + AE + + + Amazon.com.be + AMEN7PMS3EDWL + BE + + + Amazon.de + A1PA6795UKMFR9 + DE + + + Amazon.eg + ARBP9OOSHTCHU + EG + + + Amazon.es + A1RKKUPIHCS9HS + ES + + + Amazon.fr + A13V1IB3VIYZZH + FR + + + Amazon.in + A21TJRUUN4KGV + IN + + + Amazon.it + APJ6JRA9NG5V4 + IT + + + Amazon.nl + A1805IZSGTT6HS + NL + + + Amazon.pl + A1C3SOZRARQ6R3 + PL + + + Amazon.sa + A17E79C6D8DWNP + SA + + + Amazon.se + A2NODRKZP88ZB9 + SE + + + Amazon.com.tr + A33AVAJ2PDY3EV + TR + + + Amazon.co.uk + A1F83G8C2ARO7P + UK + + + + Amazon.com.au + A39IBJ37TRP1C6 + AU + + + Amazon.co.jp + A1VC38T7YXB528 + JP + + + Amazon.sg + A19VAU5U5O7RUS + SG + + + diff --git a/sale_import_amazon/models/amazon_marketplace.py b/sale_import_amazon/models/amazon_marketplace.py index ac85df9c..f5bbb343 100644 --- a/sale_import_amazon/models/amazon_marketplace.py +++ b/sale_import_amazon/models/amazon_marketplace.py @@ -1,15 +1,13 @@ # Copyright 2024 Akretion # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import _, api, fields, models +from odoo import fields, models class AmazonMarketplace(models.Model): _name = "amazon.marketplace" _description = "Amazon MarketPlace" - # List on https://developer-docs.amazon.com/sp-api/docs/marketplace-ids - # TODO: create xml data with all the Amazon Marketplaces name = fields.Char() country_code = fields.Char(required=True) - marketplace_ref = fields.Char() + marketplace_ref = fields.Char(help="API Marketplace's identifier") diff --git a/sale_import_amazon/models/queue_job_chunk.py b/sale_import_amazon/models/queue_job_chunk.py index f555279d..a13d6c93 100644 --- a/sale_import_amazon/models/queue_job_chunk.py +++ b/sale_import_amazon/models/queue_job_chunk.py @@ -1,7 +1,7 @@ # Copyright 2024 Akretion # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import _, api, fields, models +from odoo import fields, models class QueueJobChunk(models.Model): diff --git a/sale_import_amazon/models/sale_channel.py b/sale_import_amazon/models/sale_channel.py index 07499995..54d66b45 100644 --- a/sale_import_amazon/models/sale_channel.py +++ b/sale_import_amazon/models/sale_channel.py @@ -2,28 +2,28 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). import json -from pprint import pprint +from datetime import timedelta - -from odoo import _, api, Command, fields, models +from odoo import _, fields, models from odoo.exceptions import ValidationError -from odoo.addons.sale_import_amazon.utils import load_order_pages, load_order_items + +from odoo.addons.sale_import_amazon.utils import load_order_items, load_order_pages class SaleChannel(models.Model): _inherit = "sale.channel" - # Connect to API, get raw data and create chunk with raw data and processor == sale_channel_importer_amazon - type_channel = fields.Selection(selection_add=[("amazon", "Amazon")]) + channel_type = fields.Selection(selection_add=[("amazon", "Amazon")]) lwa_appid = fields.Char(string="LWA App ID") # TODO: use data_encryption to store these fields here - sp_api_refresh_token = fields.Char() - lwa_client_secret = fields.Char() + sp_api_refresh_token = fields.Char(string="SP-API Refresh Token") + lwa_client_secret = fields.Char(string="LWA Client Secret") date_last_sale_update = fields.Datetime( help="Date used to limit the API call to the last Amazon Orders updated after " - "this choosen date" + "this choosen date", + default=lambda self: fields.Datetime.now() - timedelta(days=30), ) marketplace_ids = fields.Many2many( @@ -44,11 +44,14 @@ def amazon_get_credentials(self): ) def amazon_import_orders(self): - if self.type_channel != "amazon": + self.ensure_one() + if self.channel_type != "amazon": raise ValidationError(_("The sale channel must be type 'Amazon'")) orders = [] creds = self.amazon_get_credentials() + if not self.date_last_sale_update: + raise ValidationError(_("Missing Date Last Sale Update")) date_last_sale_update = self.date_last_sale_update.isoformat(sep="T") for marketplace_id in self.marketplace_ids: @@ -63,7 +66,8 @@ def amazon_import_orders(self): return orders - def amazon_create_queue_job_chunk(self): + def amazon_import_orders_chunk(self): + self.ensure_one() orders = self.amazon_import_orders() chunk_vals = [ @@ -75,5 +79,15 @@ def amazon_create_queue_job_chunk(self): } for order in orders ] - - return self.env["queue.job.chunk"].create(chunk_vals) + chunk_ids = self.env["queue.job.chunk"].create(chunk_vals) + self.write({"date_last_sale_update": fields.Datetime.now()}) + return chunk_ids + + def amazon_import_orders_chunk_cron(self): + amazon_channel_ids = self.search([("channel_type", "=", "amazon")]) + chunk_ids = self.env["queue.job.chunk"] + for channel_id in amazon_channel_ids: + new_chunk_ids = channel_id.amazon_import_orders_chunk() + chunk_ids |= new_chunk_ids + + return chunk_ids diff --git a/sale_import_amazon/models/sale_channel_importer_amazon.py b/sale_import_amazon/models/sale_channel_importer_amazon.py index 0985b519..371f092d 100644 --- a/sale_import_amazon/models/sale_channel_importer_amazon.py +++ b/sale_import_amazon/models/sale_channel_importer_amazon.py @@ -1,7 +1,9 @@ # Copyright 2024 Akretion # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import _, api, fields, models +from odoo import _, models +from odoo.exceptions import ValidationError + from odoo.addons.sale_import_amazon.utils import get_amz_date @@ -10,8 +12,6 @@ class SaleChannelImporterAmazon(models.TransientModel): _name = "sale.channel.importer.amazon" _description = "Sale Channel Importer Amazon" - # TODO: manage existing SO shipped or canceled - def _get_line_vals(self, item): qty = item["QuantityOrdered"] discount_amount = float(item.get("PromotionDiscount", {}).get("Amount", 0)) @@ -21,7 +21,7 @@ def _get_line_vals(self, item): if discount_amount and total_incl_tax: discount = discount_amount / total_incl_tax - return { + line_vals = { "product_code": item["SellerSKU"], "description": item["Title"], "qty": qty, @@ -30,10 +30,16 @@ def _get_line_vals(self, item): "discount": discount, } + currency_code = item.get("ItemPrice", {}).get("CurrencyCode") + if currency_code: + line_vals["currency_code"] = currency_code + + return line_vals + def _get_formatted_data(self): raw = super()._get_formatted_data() - # We suppose we do not have access to Personally Identifiable Information (PII) + # We assume we do not have access to Personally Identifiable Information (PII) # about Amazon Buyers : # no customer name, only an encoded email (used as Amazon's identifier), # shipping city, zip and country code. @@ -48,6 +54,14 @@ def _get_formatted_data(self): "country_code": shipping.get("CountryCode", ""), } + marketplace_id = self.env["amazon.marketplace"].search( + [("marketplace_ref", "=", raw["MarketplaceId"])], limit=1 + ) + if not marketplace_id: + raise ValidationError( + _("Missing Amazon MarketPlace {}").format(raw["MarketplaceId"]) + ) + formatted_data = { "name": raw["AmazonOrderId"], "date_order": get_amz_date(raw["PurchaseDate"]).date(), @@ -60,6 +74,7 @@ def _get_formatted_data(self): "lines": [self._get_line_vals(item) for item in raw["OrderItems"]], "state": raw["OrderStatus"].lower(), "is_fulfilled_by_amazon": raw["FulfillmentChannel"] == "AFN", + "amazon_marketplace_id": marketplace_id.id, } if raw.get("OrderTotal"): @@ -73,15 +88,23 @@ def _get_formatted_data(self): return formatted_data def _manage_existing_so(self, existing_so, data): + # TODO: looks like it can be done better to avoid this native method shortcut + if data["state"] == "canceled" and existing_so.state != "cancel": existing_so._action_cancel() - if data["state"] == "shipped" and existing_so.delivery_status != "full": + elif data["state"] == "shipped" and existing_so.delivery_status != "full": existing_so._deliver_order_by_amazon() + else: + super()._manage_existing_so(existing_so, data) def _prepare_sale_vals(self, data): so_vals = super()._prepare_sale_vals(data) - so_vals["is_fulfilled_by_amazon"] = data["is_fulfilled_by_amazon"] - # TODO: add markerplace_id + so_vals.update( + { + "is_fulfilled_by_amazon": data["is_fulfilled_by_amazon"], + "amazon_marketplace_id": data["amazon_marketplace_id"], + } + ) return so_vals diff --git a/sale_import_amazon/models/sale_order.py b/sale_import_amazon/models/sale_order.py index 7b0feaf2..6d35d8f6 100644 --- a/sale_import_amazon/models/sale_order.py +++ b/sale_import_amazon/models/sale_order.py @@ -1,14 +1,16 @@ # Copyright 2024 Akretion # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import _, api, fields, models +from odoo import fields, models class SaleOrder(models.Model): _inherit = "sale.order" is_fulfilled_by_amazon = fields.Boolean() + amazon_marketplace_id = fields.Many2one("amazon.marketplace") def _deliver_order_by_amazon(self): + self.ensure_one() + self.sale_channel_id.amazon_location_id # TODO - pass diff --git a/sale_import_amazon/models/schemas.py b/sale_import_amazon/models/schemas.py index 27624c79..423b7f2e 100644 --- a/sale_import_amazon/models/schemas.py +++ b/sale_import_amazon/models/schemas.py @@ -1,9 +1,8 @@ # Copyright (c) Akretion 2020 # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) -from typing import List, Optional +from typing import Optional -from extendable_pydantic import ExtendableModelMeta from odoo.addons.sale_import_base.models.schemas import SaleOrder diff --git a/sale_import_amazon/security/ir.model.access.csv b/sale_import_amazon/security/ir.model.access.csv index dfb7ea8c..50295c0b 100644 --- a/sale_import_amazon/security/ir.model.access.csv +++ b/sale_import_amazon/security/ir.model.access.csv @@ -1,3 +1,3 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink sale_import_amazon.access_sale_channel_importer_amazon,access_sale_channel_importer_amazon,sale_import_amazon.model_sale_channel_importer_amazon,base.group_user,1,1,1,1 -sale_import_amazon.access_amazon_marketplace,access_amazon_marketplace,sale_import_amazon.model_amazon_marketplace,base.group_user,1,1,1,1 +sale_import_amazon.access_amazon_marketplace,access_amazon_marketplace,sale_import_amazon.model_amazon_marketplace,base.group_user,1,0,0,0 diff --git a/sale_import_amazon/tests/data.py b/sale_import_amazon/tests/data.py index 598cb03a..f31caf8d 100644 --- a/sale_import_amazon/tests/data.py +++ b/sale_import_amazon/tests/data.py @@ -32,7 +32,7 @@ "PromotionDiscountTax": {"Amount": "0.00", "CurrencyCode": "EUR"}, "QuantityOrdered": 2, "QuantityShipped": 2, - "SellerSKU": "FURN_8888", + "SellerSKU": "PROD_1", "Title": "Clever Elf - Décoration de Table de Noël", } ], diff --git a/sale_import_amazon/tests/test_sale_import_amazon.py b/sale_import_amazon/tests/test_sale_import_amazon.py index 4447cb41..567ed968 100644 --- a/sale_import_amazon/tests/test_sale_import_amazon.py +++ b/sale_import_amazon/tests/test_sale_import_amazon.py @@ -2,20 +2,14 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). import os -import datetime - - from datetime import timedelta from unittest.mock import patch -from odoo import fields, Command - +from odoo import Command, fields from odoo.tests import TransactionCase -from odoo.addons.sale_import_amazon.tests import data -from odoo.addons.sale_import_amazon.utils import get_amz_date - from odoo.addons.extendable.tests.common import ExtendableMixin +from odoo.addons.sale_import_amazon.tests import data class TestConnectorAmazon(TransactionCase, ExtendableMixin): @@ -29,32 +23,41 @@ def setUp(self): self.env = self.env( context=dict(self.env.context, test_queue_job_no_delay=True) ) - self.marketplace_id = self.env["amazon.marketplace"].create( + original_tax = self.env.ref("l10n_generic_coa.1_purchase_tax_template") + self.tax_incl = original_tax.copy({"price_include": True}) + self.product = self.env["product.product"].create( { - "name": "Amazon.fr", - "country_code": "FR", - "marketplace_ref": "A13V1IB3VIYZZH", + "name": "Product", + "default_code": "PROD_1", + "invoice_policy": "order", + "taxes_id": [Command.set([self.tax_incl.id])], } ) + self.marketplace_id = self.env.ref("sale_import_amazon.marketplace_FR") self.team_id = self.env["crm.team"].create({"name": "Test"}) + self.pricelist_id = self.env["product.pricelist"].create( + {"name": "Test EUR", "currency_id": 1} + ) self.channel_id = self.env["sale.channel"].create( { "name": "Connector Odoo-Amazon", - "type_channel": "amazon", + "channel_type": "amazon", "lwa_appid": os.environ.get("LWA_APP_ID"), "sp_api_refresh_token": os.environ.get("SP_API_REFRESH_TOKEN"), "lwa_client_secret": os.environ.get("LWA_CLIENT_SECRET"), "marketplace_ids": [Command.set([self.marketplace_id.id])], - "date_last_sale_update": fields.Datetime.now() - timedelta(days=30), + "date_last_sale_update": fields.Datetime.now() - timedelta(days=60), "crm_team_id": self.team_id.id, "sale_orders_check_amounts_total": True, - "confirm_order": False, - "invoice_order": False, + "confirm_order": True, + "invoice_order": True, + "pricelist_id": self.pricelist_id.id, } ) def test_create_queue_job_chunk(self): - chunk_ids = self.channel_id.amazon_create_queue_job_chunk() + chunk_ids = self.env["sale.channel"].amazon_import_orders_chunk_cron() + self.assertTrue(chunk_ids) for chunk_id in chunk_ids: self.assertIn("AmazonOrderId", chunk_id.data_str) self.assertEqual(chunk_id.processor, "sale_channel_importer_amazon") @@ -66,19 +69,23 @@ def test_import_order_shipped(self): return_value=data.ORDER_SHIPPED, ): old_order_ids = self.env["sale.order"].search([]) - self.channel_id.amazon_create_queue_job_chunk() + self.channel_id.amazon_import_orders_chunk() order = self.env["sale.order"].search([]) - old_order_ids self.assertEqual(order.name, "407-6462826-9892326") self.assertEqual(order.amount_total, 95.98) self.assertEqual(order.si_amount_total, 95.98) - self.assertEqual(order.date_order, datetime.datetime(2024, 1, 11)) + # TODO: how to test the date_order value before confirmation ? + # self.assertEqual(order.date_order, datetime.datetime(2024, 1, 11)) self.assertEqual(order.currency_id.id, 1) self.assertTrue(order.is_fulfilled_by_amazon) + self.assertEqual(order.team_id, self.team_id) + self.assertEqual(order.amazon_marketplace_id, self.marketplace_id) + self.assertEqual(order.state, "sale") # TODO - # self.assertEqual(order.state, "done") # self.assertEqual(order.delivery_status, "full") + # self.assertEqual(order.state, "done") partner = order.partner_id self.assertEqual(partner.name, "Amazon Customer #407-6462826-9892326") @@ -95,7 +102,7 @@ def test_import_order_shipped(self): self.assertEqual(line.discount, 0) self.assertEqual(line.price_unit, 47.99) self.assertEqual(line.product_uom_qty, 2) - self.assertEqual(line.product_id.default_code, "FURN_8888") + self.assertEqual(line.product_id.default_code, "PROD_1") def test_import_order_canceled(self): pass diff --git a/sale_import_amazon/utils.py b/sale_import_amazon/utils.py index f2fd9992..73f82043 100644 --- a/sale_import_amazon/utils.py +++ b/sale_import_amazon/utils.py @@ -1,8 +1,7 @@ import dateutil - -from sp_api.base import Marketplaces from sp_api.api import Orders -from sp_api.util import throttle_retry, load_all_pages +from sp_api.base import Marketplaces +from sp_api.util import load_all_pages, throttle_retry @throttle_retry() diff --git a/sale_import_amazon/views/amazon_marketplace.xml b/sale_import_amazon/views/amazon_marketplace.xml index 10e365d2..d112e7c8 100644 --- a/sale_import_amazon/views/amazon_marketplace.xml +++ b/sale_import_amazon/views/amazon_marketplace.xml @@ -1,42 +1,43 @@ - + - - + amazon.marketplace.form (in sale_import_amazon) amazon.marketplace
- +
- - + + + + -
- + amazon.marketplace.tree (in sale_import_amazon) amazon.marketplace - - + + + - - Amazon Marketplace + + Amazon Marketplace amazon.marketplace tree,form [] @@ -45,9 +46,9 @@ Amazon Marketplace - - - + + +
diff --git a/sale_import_amazon/views/sale_channel.xml b/sale_import_amazon/views/sale_channel.xml index 8513a7a9..3119bee1 100644 --- a/sale_import_amazon/views/sale_channel.xml +++ b/sale_import_amazon/views/sale_channel.xml @@ -1,15 +1,37 @@ - + - sale.channel.form (in sale_import_amazon) sale.channel - + - + + + + + + + + + + + + +