# 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.transaction import Transaction from .common import IdentifierMixin from .exceptions import ShopifyError class Sale(IdentifierMixin, metaclass=PoolMeta): __name__ = 'sale.sale' shopify_tax_adjustment = Monetary( "Shopify Tax Adjustment", currency='currency', digits='currency', readonly=True) @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'), ] @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 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') if getattr(order, 'customer', None): party = Party.get_from_shopify(shop, order.customer) party.save() party.set_shopify_identifier(shop, order.customer.id) else: party = Party() party.save() if not sale: sale = shop.get_sale(party=party) sale.shopify_identifier = order.id assert sale.shopify_identifier == order.id if order.location_id: for shop_warehouse in shop.shopify_warehouses: if shop_warehouse.shopify_id == str(order.location_id): sale.warehouse = shop_warehouse.warehouse break if sale.currency.code != order.currency: sale.currency, = Currency.search([ ('code', '=', order.currency), ], limit=1) if getattr(order, 'shipping_address', None): sale.shipment_address = party.get_address_from_shopify( order.shipping_address) if getattr(order, 'billing_address', None): sale.invoice_address = party.get_address_from_shopify( order.billing_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 sale.reference = order.name sale.comment = order.note sale.sale_date = dateutil.parser.isoparse( order.processed_at or order.created_at).date() if order.phone: for contact_mechanism in party.contact_mechanisms: if (contact_mechanism.type in {'phone', 'mobile'} and contact_mechanism.value == order.phone): break else: contact_mechanism = ContactMechanism( party=party, type='phone', value=order.phone) sale.contact = contact_mechanism refund_line_items = defaultdict(list) for refund in order.refunds: for refund_line_item in refund.refund_line_items: refund_line_items[refund_line_item.line_item_id].append( refund_line_item) 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 = [] for line_item in order.line_items: line = id2line.pop(line_item.id, None) quantity = line_item.quantity for refund_line_item in refund_line_items[line_item.id]: if refund_line_item.restock_type == 'cancel': quantity -= refund_line_item.quantity lines.append(Line.get_from_shopify( sale, line_item, quantity, line=line)) for shipping_line, line in zip_longest( order.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 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 not line.product and line.shopify_identifier: raise SaleConfirmError( gettext('web_shop_shopify' '.msg_sale_line_without_product', 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') with self.web_shop.shopify_session(): for shipment in self.shipments: fulfillment = shipment.get_shopify(self) if fulfillment: if not fulfillment.save(): raise ShopifyError(gettext( 'web_shop_shopify.msg_fulfillment_fail', sale=self.rec_name, error="\n".join( fulfillment.errors.full_messages()))) shipment.set_shopify_identifier(self, fulfillment.id) Transaction().commit() # TODO: manage drop shipment if self.shipment_state == 'sent': # TODO: manage shopping refund refund = self.get_shopify_refund(shipping={ 'full_refund': False, }) if refund: if not refund.save(): raise ShopifyError(gettext( 'web_shop_shopify.msg_refund_fail', sale=self.rec_name, error="\n".join( refund.errors.full_messages()))) order = shopify.Order.find(self.shopify_identifier) Payment.get_from_shopify(self, order) if self.state == 'done': order = shopify.Order.find(self.shopify_identifier) order.close() def get_shopify_refund(self, shipping): order = shopify.Order.find(self.shopify_identifier) fulfillable_quantities = { l.id: l.fulfillable_quantity for l in order.line_items} refund_line_items = list( self.get_shopify_refund_line_items(fulfillable_quantities)) if not refund_line_items: return refund = shopify.Refund.calculate( self.shopify_identifier, shipping={ 'full_refund': False, }, refund_line_items=refund_line_items) refund.refund_line_items = refund_line_items for transaction in refund.transactions: transaction.kind = 'refund' return refund 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): 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 { 'line_item_id': line.shopify_identifier, 'quantity': int(quantity), 'restock_type': 'cancel', 'location_id': location_id, } 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 get_from_shopify(cls, shop, order, sale=None): pool = Pool() Tax = pool.get('account.tax') sale = super().get_from_shopify(shop, order, sale=sale) sale.shipment_cost_method = 'order' if order.shipping_lines: available_carriers = sale.on_change_with_available_carriers() if available_carriers: sale.carrier = available_carriers[0] if sale.carrier: 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) line.product = sale.carrier.carrier_product line.on_change_product() line.unit_price = round_price(Tax.reverse_compute( unit_price, line.taxes, sale.sale_date)) if base_price is not None: line.base_price = round_price(Tax.reverse_compute( base_price, line.taxes, sale.sale_date)) return sale class Line(IdentifierMixin, metaclass=PoolMeta): __name__ = 'sale.line' @classmethod def get_from_shopify(cls, sale, line_item, quantity, 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 = line_item.id assert line.shopify_identifier == line_item.id if getattr(line_item, 'variant_id', None): line.product = Product.search_shopify_identifier( sale.web_shop, line_item.variant_id) else: line.product = None if line.product: line._set_shopify_quantity(line.product, quantity) line.on_change_product() else: line.quantity = quantity line.description = line_item.title line.taxes = [] total_discount = sum( Decimal(d.amount) for d in line_item.discount_allocations) unit_price = (( (Decimal(line_item.price) * line_item.quantity) - Decimal(total_discount)) / 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: line.unit_price = unit_price return line def _set_shopify_quantity(self, product, quantity): if product.shopify_uom.category == product.sale_uom.category: self.unit = self.product.shopify_uom self.quantity = quantity def _set_shopify_unit_price(self, product, unit_price): if product.shopify_uom.category == product.sale_uom.category: self.unit_price = unit_price @classmethod def get_from_shopify_shipping(cls, sale, shipping_line, line=None): if not line: line = cls(type='line') line.sale = sale line.quantity = 1 line.unit_price = round_price(Decimal(shipping_line.discounted_price)) line.description = shipping_line.title return line 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, line=None): pool = Pool() Tax = pool.get('account.tax') line = super().get_from_shopify(sale, line_item, quantity, line=line) line.base_price = round_price(Tax.reverse_compute( Decimal(line_item.price), 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) line.base_price = Decimal(shipping_line.price) 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): self.unit = product.sale_uom self.secondary_unit = product.shopify_uom self.on_change_product() 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): 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) line.shipment_cost = Decimal(shipping_line.price) return line # TODO: refund as return sale