# This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. from collections import defaultdict from decimal import Decimal from itertools import zip_longest import dateutil import shopify from trytond.i18n import gettext from trytond.model import ModelView, Unique, fields from trytond.modules.currency.fields import Monetary from trytond.modules.product import round_price from trytond.modules.sale.exceptions import SaleConfirmError from trytond.pool import Pool, PoolMeta from trytond.pyson import Eval from trytond.transaction import Transaction from . import graphql from .common import IdentifierMixin, gid2id, id2gid, setattr_changed from .exceptions import ShopifyError from .shopify_retry import GraphQLException QUERY_ORDER = '''\ query GetOrder($id: ID!) { order(id: $id) %(fields)s }''' QUERY_ORDER_CURSOR = '''\ query GetOrder($id: ID!, $cursor: String) { order(id: $id) %(fields)s }''' QUERY_ORDER_CLOSED = '''\ query GetOrderClosed($id: ID!) { order(id: $id) { closed } }''' QUERY_ORDER_FULFILLABLE_QUANTITIES = '''\ query GetOrderFulfillableQuantities($id: ID!, $cursor: String) { order(id: $id) { lineItems(first: 250, after: $cursor) { nodes { id fulfillableQuantity } pageInfo { hasNextPage endCursor } } } }''' QUERY_ORDER_SUGGESTED_REFUND = '''\ query GetOrderSuggestedRefund( $id: ID!, $refundShipping: Boolean, $refundLineItems: [RefundLineItemInput!]) { order(id: $id) { suggestedRefund( refundShipping: $refundShipping, refundLineItems: $refundLineItems) { suggestedTransactions { amountSet { presentmentMoney { amount currencyCode } } gateway kind parentTransaction { id } } } } }''' MUTATION_ORDER_CLOSE = '''\ mutation OrderClose($input: OrderCloseInput!) { orderClose(input: $input) { userErrors { field message } } }''' MUTATION_ORDER_OPEN = '''\ mutation OrderOpen($input: OrderOpenInput!) { orderOpen(input: $input) { userErrors { field message } } }''' QUERY_FULFILLMENT_ORDER = '''\ query GetFulfillmentOrder($id: ID!, $cursor: String) { fulfillmentOrder(id: $id) %(fields)s }''' MUTATION_FULFILLMENT_CREATE = '''\ mutation fulfillmentCreate($fulfillment: FulfillmentInput!) { fulfillmentCreate(fulfillment: $fulfillment) { fulfillment { id } userErrors { field message } } }''' MUTATION_FULFILLMENT_CANCEL = '''\ mutation fulfillmentCancel($id: ID!) { fulfillmentCancel(id: $id) { userErrors { field message } } }''' QUERY_REFUND_CURSOR = '''\ query GetRefund($id: ID!, $cursor: String) { refund(id: $id) %(fields)s }''' MUTATION_REFUND_CREATE = '''\ mutation RefundCreate($input: RefundInput!) { refundCreate(input: $input) { userErrors { field message } } }''' class Sale(IdentifierMixin, metaclass=PoolMeta): __name__ = 'sale.sale' shopify_tax_adjustment = Monetary( "Shopify Tax Adjustment", currency='currency', digits='currency', readonly=True) shopify_status_url = fields.Char("Shopify Status URL", readonly=True) shopify_resource = 'orders' @classmethod def __setup__(cls): super().__setup__() t = cls.__table__() cls._sql_constraints += [ ('web_shop_shopify_identifier_unique', Unique(t, t.web_shop, t.shopify_identifier_signed), 'web_shop_shopify.msg_identifier_sale_web_shop_unique'), ] def get_web_status_url(self, name): url = super().get_web_status_url(name) if self.shopify_status_url: url = self.shopify_status_url return url @fields.depends('shopify_tax_adjustment') def get_tax_amount(self): amount = super().get_tax_amount() if self.shopify_tax_adjustment: amount += self.shopify_tax_adjustment return amount @classmethod def shopify_fields(cls): pool = Pool() Party = pool.get('party.party') Address = pool.get('party.address') Line = pool.get('sale.line') Payment = pool.get('account.payment') return { 'id': None, 'customer': Party.shopify_fields(), 'presentmentCurrencyCode': None, 'shippingAddress': Address.shopify_fields(), 'billingAddress': Address.shopify_fields(), 'name': None, 'note': None, 'processedAt': None, 'createdAt': None, 'phone': None, 'refunds': { 'id': None, 'refundLineItems(first: 10)': { 'nodes': { 'lineItem': { 'id': None, }, 'restockType': None, 'quantity': None, }, 'pageInfo': { 'hasNextPage': None, 'endCursor': None, }, }, }, 'lineItems(first: 100)': { 'nodes': Line.shopify_fields(), 'pageInfo': { 'hasNextPage': None, 'endCursor': None, }, }, 'shippingLines(first: 10)': { 'nodes': { 'title': None, 'currentDiscountedPriceSet': { 'presentmentMoney': { 'amount': None, }, }, }, 'pageInfo': { 'hasNextPage': None, 'endCursor': None, }, }, 'fulfillmentOrders(first: 10)': { 'nodes': { 'id': None, 'assignedLocation': { 'location': { 'id': None, }, }, 'lineItems(first: 100)': { 'nodes': { 'lineItem': { 'id': None, }, 'totalQuantity': None, 'remainingQuantity': None, }, 'pageInfo': { 'hasNextPage': None, 'endCursor': None, }, }, }, 'pageInfo': { 'hasNextPage': None, 'endCursor': None, } }, 'transactions': Payment.shopify_fields(), 'totalPriceSet': { 'presentmentMoney': { 'amount': None, }, }, 'currentTotalPriceSet': { 'presentmentMoney': { 'amount': None, }, }, 'statusPageUrl': None, } @classmethod def get_from_shopify(cls, shop, order, sale=None): pool = Pool() Party = pool.get('party.party') Address = pool.get('party.address') ContactMechanism = pool.get('party.contact_mechanism') Currency = pool.get('currency.currency') Line = pool.get('sale.line') shopify_fields = cls.shopify_fields() if order.get('customer'): party = Party.get_from_shopify(shop, order['customer']) party.save() party.set_shopify_identifier(shop, gid2id(order['customer']['id'])) else: party = shop.guest_party if not sale: sale = shop.get_sale(party=party) sale.web_id = str(gid2id(order['id'])) sale.shopify_identifier = gid2id(order['id']) shopify_fulfillments = graphql.iterate( QUERY_ORDER_CURSOR % { 'fields': graphql.selection({ 'fulfillmentOrders(first: 10, after: $cursor)': ( shopify_fields[ 'fulfillmentOrders(first: 10)']), }), }, {'id': order['id']}, 'order', 'fulfillmentOrders', order) location_ids = { str(gid2id(f['assignedLocation']['location']['id'])) for f in shopify_fulfillments} for location_id in location_ids: for shop_warehouse in shop.shopify_warehouses: if shop_warehouse.shopify_id == location_id: sale.warehouse = shop_warehouse.warehouse break setattr_changed(sale, 'party', party) assert sale.shopify_identifier == gid2id(order['id']) if sale.currency.code != order['presentmentCurrencyCode']: sale.currency, = Currency.search([ ('code', '=', order['presentmentCurrencyCode']), ], limit=1) if sale.party != shop.guest_party: if order.get('shippingAddress'): shipment_address = party.get_address_from_shopify( order['shippingAddress']) else: shipment_address = None if order.get('billingAddress'): invoice_address = party.get_address_from_shopify( order['billingAddress']) else: invoice_address = None else: shipment_address = sale.party.address_get(type='delivery') invoice_address = sale.party.address_get(type='invoice') if shipment_address: setattr_changed(sale, 'shipment_address', shipment_address) if invoice_address or shipment_address: setattr_changed( sale, 'invoice_address', invoice_address or shipment_address) if not party.addresses: address = Address(party=party) address.save() if not sale.shipment_address: sale.shipment_address = address if not sale.invoice_address: sale.invoice_address = address setattr_changed(sale, 'reference', order['name']) setattr_changed(sale, 'shopify_status_url', order['statusPageUrl']) setattr_changed(sale, 'comment', order['note']) setattr_changed(sale, 'sale_date', dateutil.parser.isoparse( order['processedAt'] or order['createdAt']).date()) if order['phone']: for contact_mechanism in party.contact_mechanisms: if (contact_mechanism.type in {'phone', 'mobile'} and (contact_mechanism.value_compact == contact_mechanism.format_value_compact( order['phone'], contact_mechanism.type))): break else: contact_mechanism = ContactMechanism( party=party, type='phone', value=order['phone']) setattr_changed(sale, 'contact', contact_mechanism) refund_line_items = defaultdict(list) for refund in order['refunds']: shopify_refund_line_items = graphql.iterate( QUERY_REFUND_CURSOR % { 'fields': graphql.selection({ 'refundLineItems(first: 10, after: $cursor)': ( shopify_fields['refunds'][ 'refundLineItems(first: 10)']), }), }, {'id': refund['id']}, 'refund', 'refundLineItems', refund) for refund_line_item in shopify_refund_line_items: line_item_id = gid2id(refund_line_item['lineItem']['id']) refund_line_items[line_item_id].append(refund_line_item) line2warehouses = defaultdict(set) shopify_fulfillment_orders = graphql.iterate( QUERY_ORDER_CURSOR % { 'fields': graphql.selection({ 'fulfillmentOrders(first: 10, after: $cursor)': ( shopify_fields[ 'fulfillmentOrders(first: 10)']), }), }, {'id': order['id']}, 'order', 'fulfillmentOrders', order) for fulfillment_order in shopify_fulfillment_orders: location_id = str(gid2id( fulfillment_order['assignedLocation']['location']['id'])) for shop_warehouse in shop.shopify_warehouses: if shop_warehouse.shopify_id == location_id: warehouse = shop_warehouse.warehouse break else: continue shopify_line_items = graphql.iterate( QUERY_FULFILLMENT_ORDER % { 'fields': graphql.selection({ 'lineItems(first: 100, after: $cursor)': ( shopify_fields[ 'fulfillmentOrders(first: 10)'][ 'nodes'][ 'lineItems(first: 100)']), }), }, {'id': fulfillment_order['id']}, 'fulfillmentOrder', 'lineItems', fulfillment_order) for line_item in shopify_line_items: if line_item['remainingQuantity']: line2warehouses[gid2id(line_item['lineItem']['id'])].add( warehouse) id2line = { l.shopify_identifier: l for l in getattr(sale, 'lines', []) if l.shopify_identifier} shipping_lines = [ l for l in getattr(sale, 'lines', []) if not l.shopify_identifier] lines = [] shopify_line_items = graphql.iterate( QUERY_ORDER_CURSOR % { 'fields': graphql.selection({ 'lineItems(first: 100, after: $cursor)': ( shopify_fields['lineItems(first: 100)']), }), }, {'id': order['id']}, 'order', 'lineItems', order) for line_item in shopify_line_items: line_item_id = gid2id(line_item['id']) line = id2line.pop(line_item_id, None) warehouses = line2warehouses[line_item_id] warehouse = None if len(warehouses) == 1: warehouse = warehouses.pop() elif not warehouses: if line: # keep existing warehouse # if the line has already been shipped # (no remaining quantity) warehouse = line.shopify_warehouse if not warehouse: warehouse = sale.warehouse quantity = line_item['quantity'] for refund_line_item in refund_line_items[ gid2id(line_item['id'])]: quantity -= refund_line_item['quantity'] lines.append(Line.get_from_shopify( sale, line_item, quantity, warehouse=warehouse, line=line)) shopify_shipping_lines = graphql.iterate( QUERY_ORDER_CURSOR % { 'fields': graphql.selection({ 'shippingLines(first: 10, after: $cursor)': ( shopify_fields['shippingLines(first: 10)']), }), }, {'id': order['id']}, 'order', 'shippingLines', order) for shipping_line, line in zip_longest( shopify_shipping_lines, shipping_lines): if shipping_line: line = Line.get_from_shopify_shipping( sale, shipping_line, line=line) else: line.quantity = 0 lines.append(line) for line in id2line.values(): line.quantity = 0 lines.append(line) sale.lines = lines return sale @property def invoice_grouping_method(self): method = super().invoice_grouping_method if self.web_shop and self.web_shop.type == 'shopify': # Can not group in order to spread tax adjustment method = None return method def create_invoice(self): pool = Pool() Currency = pool.get('currency.currency') invoice = super().create_invoice() if invoice and self.shopify_tax_adjustment: invoice.save() adjustment = Currency.compute( self.currency, self.shopify_tax_adjustment, invoice.currency, round=False) untaxed_amount = Currency.compute( self.currency, self.untaxed_amount, invoice.currency, round=False) remaining = invoice.currency.round( adjustment * (invoice.untaxed_amount / untaxed_amount)) taxes = invoice.taxes for tax in taxes: if tax.amount: if invoice.tax_amount: ratio = tax.amount / invoice.tax_amount else: ratio = 1 / len(invoice.taxes) value = invoice.currency.round(adjustment * ratio) tax.amount += value remaining -= value if remaining: for tax in taxes: if tax.amount: tax.amount += remaining break invoice.taxes = taxes invoice.save() return invoice @classmethod @ModelView.button def process(cls, sales): for sale in sales: for line in sale.lines: if line.shopify_identifier and line.quantity: if not line.product: raise SaleConfirmError( gettext('web_shop_shopify' '.msg_sale_line_without_product', sale=sale.rec_name, line=line.rec_name)) if not line.shopify_warehouse and line.movable: raise SaleConfirmError( gettext('web_shop_shopify' '.msg_sale_line_without_warehouse', sale=sale.rec_name, line=line.rec_name)) super().process(sales) for sale in sales: if not sale.web_shop or not sale.shopify_identifier: continue cls.__queue__._process_shopify(sale) def _process_shopify(self): """Sent updates to shopify The transaction is committed if fulfillment is created. """ pool = Pool() Payment = pool.get('account.payment') self.lock() with self.web_shop.shopify_session(): for shipment in self.shipments: fulfillment = shipment.get_shopify(self) if fulfillment: try: result = shopify.GraphQL().execute( MUTATION_FULFILLMENT_CREATE, {'fulfillment': fulfillment} )['data']['fulfillmentCreate'] if errors := result.get('userErrors'): raise GraphQLException({'errors': errors}) fulfillment = result['fulfillment'] except GraphQLException as e: raise ShopifyError(gettext( 'web_shop_shopify.msg_fulfillment_fail', sale=self.rec_name, error="\n".join( err['message'] for err in e.errors)) ) from e shipment.set_shopify_identifier( self, gid2id(fulfillment['id'])) Transaction().commit() # Start a new transaction as commit release the lock self.__class__.__queue__._process_shopify(self) return elif shipment.state == 'cancelled': fulfillment_id = shipment.get_shopify_identifier(self) if fulfillment_id: fulfillment_id = id2gid('Fulfillment', fulfillment_id) result = shopify.GraphQL().execute( MUTATION_FULFILLMENT_CANCEL, {'id': fulfillment_id} )['data']['fulfillmentCancel'] if errors := result.get('userErrors'): raise GraphQLException({'errors': errors}) # TODO: manage drop shipment shopify_id = id2gid('Order', self.shopify_identifier) if self.shipment_state == 'sent' or self.state == 'done': # TODO: manage shopping refund refund = self.get_shopify_refund( shipping=self.shipment_state == 'none') if refund: try: result = shopify.GraphQL().execute( MUTATION_REFUND_CREATE, {'input': refund})['data']['refundCreate'] if errors := result.get('userErrors'): raise GraphQLException({'errors': errors}) except GraphQLException as e: raise ShopifyError(gettext( 'web_shop_shopify.msg_refund_fail', sale=self.rec_name, error="\n".join( err['message'] for err in e.errors)) ) from e order = shopify.GraphQL().execute( QUERY_ORDER % { 'fields': graphql.selection(self.shopify_fields()), }, {'id': shopify_id})['data']['order'] Payment.get_from_shopify(self, order) shopify_id = id2gid('Order', self.shopify_identifier) order = shopify.GraphQL().execute( QUERY_ORDER_CLOSED, {'id': shopify_id})['data']['order'] if self.state == 'done': if not order['closed']: result = shopify.GraphQL().execute( MUTATION_ORDER_CLOSE, { 'input': { 'id': shopify_id, }, })['data']['orderClose'] if errors := result.get('userErrors'): raise GraphQLException({'errors': errors}) elif order['closed']: result = shopify.GraphQL().execute( MUTATION_ORDER_OPEN, { 'input': { 'id': shopify_id, }, })['data']['orderOpen'] if errors := result.get('userErrors'): raise GraphQLException({'errors': errors}) def get_shopify_refund(self, shipping=False): order_id = id2gid('Order', self.shopify_identifier) shopify_line_items = graphql.iterate( QUERY_ORDER_FULFILLABLE_QUANTITIES, {'id': order_id}, 'order', 'lineItems') fulfillable_quantities = { gid2id(l['id']): l['fulfillableQuantity'] for l in shopify_line_items} refund_line_items = list( self.get_shopify_refund_line_items(fulfillable_quantities)) if not refund_line_items: return order = shopify.GraphQL().execute( QUERY_ORDER_SUGGESTED_REFUND, { 'id': order_id, 'refundShipping': shipping, 'refundLineItems': refund_line_items})['data']['order'] currencies = set() transactions = [] for transaction in order['suggestedRefund']['suggestedTransactions']: amount = transaction['amountSet']['presentmentMoney']['amount'] currencies.add( transaction['amountSet']['presentmentMoney']['currencyCode']) transactions.append({ 'amount': amount, 'gateway': transaction['gateway'], 'kind': 'REFUND', 'orderId': order_id, 'parentId': transaction['parentTransaction']['id'], }) if not transactions: return currency, = currencies return { 'orderId': order_id, 'currency': currency, 'refundLineItems': refund_line_items, 'shipping': { 'fullRefund': shipping, }, 'transactions': transactions, } def get_shopify_refund_line_items(self, fulfillable_quantities): pool = Pool() Uom = pool.get('product.uom') assert self.shipment_state == 'sent' location_id = None for shop_warehouse in self.web_shop.shopify_warehouses: if shop_warehouse.warehouse == self.warehouse: location_id = shop_warehouse.shopify_id for line in self.lines: if (line.type != 'line' or not line.shopify_identifier or not line.movable): continue fulfillable_quantity = fulfillable_quantities.get( line.shopify_identifier, 0) quantity = line.quantity for move in line.moves: if move.state == 'done': quantity -= Uom.compute_qty( move.unit, move.quantity, line.unit) quantity = min(fulfillable_quantity, quantity) if quantity > 0: yield { 'lineItemId': id2gid('LineItem', line.shopify_identifier), 'locationId': id2gid('Location', location_id), 'quantity': int(quantity), 'restockType': 'CANCEL', } class Sale_ShipmentCost(metaclass=PoolMeta): __name__ = 'sale.sale' def set_shipment_cost(self): if self.web_shop and self.web_shop.type == 'shopify': return [] return super().set_shipment_cost() @classmethod def shopify_fields(cls): fields = super().shopify_fields() shipping_line = fields.setdefault('shippingLine', {}) shipping_line.setdefault('code') shipping_line.setdefault('title') return fields @classmethod def get_from_shopify(cls, shop, order, sale=None): pool = Pool() Tax = pool.get('account.tax') sale = super().get_from_shopify(shop, order, sale=sale) shipment_cost_method = None if shipping_line := order['shippingLine']: available_carriers = sale.on_change_with_available_carriers() carrier = None for carrier in available_carriers: if carrier.shopify_match(shop, shipping_line): carrier = carrier break else: if available_carriers: carrier = available_carriers[0] setattr_changed(sale, 'carrier', carrier) if sale.carrier: shipment_cost_method = 'order' for line in sale.lines: if getattr(line, 'shipment_cost', None) is not None: unit_price = line.unit_price base_price = getattr(line, 'base_price', None) if setattr_changed( line, 'product', sale.carrier.carrier_product): line.on_change_product() unit_price = round_price(Tax.reverse_compute( unit_price, line.taxes, sale.sale_date)) setattr_changed(line, 'unit_price', unit_price) if base_price is not None: base_price = round_price(Tax.reverse_compute( base_price, line.taxes, sale.sale_date)) setattr_changed(line, 'base_price', base_price) setattr_changed(sale, 'shipment_cost_method', shipment_cost_method) return sale class Line(IdentifierMixin, metaclass=PoolMeta): __name__ = 'sale.line' shopify_warehouse = fields.Many2One( 'stock.location', "Shopify Warehouse", readonly=True) @fields.depends('shopify_warehouse') def on_change_with_warehouse(self, name=None): warehouse = super().on_change_with_warehouse(name=name) if self.shopify_warehouse: warehouse = self.shopify_warehouse return warehouse @classmethod def __setup__(cls): super().__setup__() cls.product.states['readonly'] = ( cls.product.states['readonly'] & ~((Eval('sale_state') == 'confirmed') & Eval('shopify_identifier'))) @classmethod def shopify_fields(cls): return { 'id': None, 'quantity': None, 'variant': { 'id': None, }, 'variantTitle': None, 'discountAllocations': { 'allocatedAmountSet': { 'presentmentMoney': { 'amount': None, }, }, }, 'originalUnitPriceSet': { 'presentmentMoney': { 'amount': None, }, }, } @classmethod def get_from_shopify( cls, sale, line_item, quantity, warehouse=None, line=None): pool = Pool() Product = pool.get('product.product') Tax = pool.get('account.tax') if not line: line = cls(type='line') line.sale = sale line.shopify_identifier = gid2id(line_item['id']) line.product = None line.shopify_warehouse = warehouse assert line.shopify_identifier == gid2id(line_item['id']) if line_item['variant'] and line_item['variant']['id']: if product := Product.search_shopify_identifier( sale.web_shop, gid2id(line_item['variant']['id'])): setattr_changed(line, 'product', product) if line.product: line._set_shopify_quantity(line.product, quantity) if line._changed_values(): line.on_change_product() else: setattr_changed(line, 'quantity', quantity) setattr_changed(line, 'description', line_item['variantTitle']) setattr_changed(line, 'taxes', ()) total_discount = sum( Decimal(d['allocatedAmountSet']['presentmentMoney']['amount']) for d in line_item['discountAllocations']) unit_price = Decimal( line_item['originalUnitPriceSet']['presentmentMoney']['amount']) if line_item['quantity']: unit_price *= line_item['quantity'] unit_price -= total_discount if line_item['quantity']: unit_price /= line_item['quantity'] unit_price = round_price(Tax.reverse_compute( unit_price, line.taxes, sale.sale_date)) if line.product: line._set_shopify_unit_price(line.product, unit_price) else: setattr_changed(line, 'unit_price', unit_price) return line def _set_shopify_quantity(self, product, quantity): if product.shopify_uom.category == product.sale_uom.category: setattr_changed(self, 'unit', self.product.shopify_uom) setattr_changed(self, 'quantity', quantity) def _set_shopify_unit_price(self, product, unit_price): if product.shopify_uom.category == product.sale_uom.category: setattr_changed(self, 'unit_price', unit_price) @classmethod def get_from_shopify_shipping(cls, sale, shipping_line, line=None): pool = Pool() Tax = pool.get('account.tax') if not line: line = cls(type='line') line.sale = sale line.product = None line._set_shopify_shipping_product(sale, shipping_line) setattr_changed(line, 'quantity', 1) if line.product: if line._changed_values: line.on_change_product() else: setattr_changed(line, 'taxes', ()) unit_price = Decimal( shipping_line['currentDiscountedPriceSet'] ['presentmentMoney']['amount']) unit_price = round_price(Tax.reverse_compute( unit_price, line.taxes, sale.sale_date)) setattr_changed(line, 'unit_price', unit_price) setattr_changed(line, 'description', shipping_line['title']) return line def _set_shopify_shipping_product(self, sale, shipping_line): pass def _get_invoice_line_quantity(self): quantity = super()._get_invoice_line_quantity() if self.sale.web_shop and self.sale.web_shop.type == 'shopify': if (self.sale.get_shipment_state() != 'sent' and any(l.product.type != 'service' for l in self.sale.lines if l.product)): quantity = 0 return quantity class Line_Discount(metaclass=PoolMeta): __name__ = 'sale.line' @classmethod def get_from_shopify( cls, sale, line_item, quantity, warehouse=None, line=None): pool = Pool() Tax = pool.get('account.tax') line = super().get_from_shopify( sale, line_item, quantity, warehouse=warehouse, line=line) amount = Decimal( line_item['originalUnitPriceSet']['presentmentMoney']['amount']) setattr_changed(line, 'base_price', round_price( Tax.reverse_compute(amount, line.taxes, sale.sale_date))) return line @classmethod def get_from_shopify_shipping(cls, sale, shipping_line, line=None): line = super().get_from_shopify_shipping( sale, shipping_line, line=line) setattr_changed(line, 'base_price', Decimal( shipping_line['currentDiscountedPriceSet']['presentmentMoney'] ['amount'])) return line class Line_SaleSecondaryUnit(metaclass=PoolMeta): __name__ = 'sale.line' def _set_shopify_quantity(self, product, quantity): super()._set_shopify_quantity(product, quantity) if (product.sale_secondary_uom and product.shopify_uom.category == product.sale_secondary_uom.category): changed = setattr_changed(self, 'unit', product.sale_uom) changed |= setattr_changed( self, 'secondary_unit', product.shopify_uom) if changed: self.on_change_product() if setattr_changed(self, 'secondary_quantity', quantity): self.on_change_secondary_quantity() def _set_shopify_unit_price(self, product, unit_price): super()._set_shopify_unit_price(product, unit_price) if (product.sale_secondary_uom and product.shopify_uom.category == product.sale_secondary_uom.category): if setattr_changed(self, 'secondary_unit_price', unit_price): self.on_change_secondary_unit_price() class Line_ShipmentCost(metaclass=PoolMeta): __name__ = 'sale.line' @classmethod def get_from_shopify_shipping(cls, sale, shipping_line, line=None): line = super().get_from_shopify_shipping( sale, shipping_line, line=line) setattr_changed(line, 'shipment_cost', Decimal( shipping_line['currentDiscountedPriceSet']['presentmentMoney'] ['amount'])) return line def _set_shopify_shipping_product(self, sale, shipping_line): super()._set_shopify_shipping_product(sale, shipping_line) if sale.carrier: setattr_changed(self, 'product', sale.carrier.carrier_product) class Line_Kit(metaclass=PoolMeta): __name__ = 'sale.line' @classmethod def get_from_shopify( cls, sale, line_item, quantity, warehouse=None, line=None): pool = Pool() UoM = pool.get('product.uom') Component = pool.get('sale.line.component') line = super().get_from_shopify( sale, line_item, quantity, warehouse=warehouse, line=line) if getattr(line, 'components', None): quantity = UoM.compute_qty( line.unit, line.quantity, line.product.default_uom, round=False) for component in line.components: if not component.fixed: quantity = component.unit.round( quantity * component.quantity_ratio) setattr_changed(component, 'quantity', quantity) line.components = line.components elif (getattr(sale, 'state', 'draft') != 'draft' and line.product and line.product.type == 'kit'): components = [] for component in line.product.components_used: components.append(line.get_component(component)) Component.set_price_ratio(components, line.quantity) line.components = components return line # TODO: refund as return sale