447 lines
17 KiB
Python
Executable File
447 lines
17 KiB
Python
Executable File
# 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
|