Files
tryton/modules/web_shop_shopify/sale.py
T
Cédric Krier f33ae566f3 Do not set a shipment cost method for Shopify order without shipping line
Without a shipping line, there is no carrier selected and thus no shipment cost
method to set.

Closes #14976
(grafted from 313b466eb1c55ac922819a5b66dc49e081e85064)

--HG--
branch : 8.0
2026-07-29 19:30:48 +02:00

1102 lines
40 KiB
Python

# 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
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 GraphQLException, ShopifyError
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_amount_to_pay = Monetary(
"Shopify Amount to Pay",
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_url(self, name):
url = super().get_web_url(name)
if self.shopify_url:
url = self.shopify_url
return url
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,
},
},
'refundShippingLines(first: 10)': {
'nodes': {
'shippingLine': {
'id': None,
},
},
'pageInfo': {
'hasNextPage': None,
'endCursor': None,
},
},
'orderAdjustments(first: 10)': {
'nodes': {
'amountSet': {
'presentmentMoney': {
'amount': None,
},
},
'taxAmountSet': {
'presentmentMoney': {
'amount': None,
},
},
},
},
'totalRefundedSet': {
'presentmentMoney': {
'amount': 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,
},
},
'deliveryMethod': {
'methodType': None,
},
'lineItems(first: 100)': {
'nodes': {
'lineItem': {
'id': None,
},
'totalQuantity': None,
'remainingQuantity': None,
},
'pageInfo': {
'hasNextPage': None,
'endCursor': None,
},
},
'status': None,
},
'pageInfo': {
'hasNextPage': None,
'endCursor': None,
}
},
'paymentTerms': {
'paymentTermsType': None,
},
'transactions': Payment.shopify_fields(),
'totalPriceSet': {
'presentmentMoney': {
'amount': None,
},
},
'currentTotalPriceSet': {
'presentmentMoney': {
'amount': None,
},
},
'totalRefundedSet': {
'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(
shop, 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 = None
invoice_address = sale.party.address_get(type='invoice')
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)
refunded_amount = Decimal(0)
for refund in order['refunds']:
shopify_refund_line_items = graphql.iterate(
shop, 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)
if refund['orderAdjustments']['nodes']:
refunded_amount += Decimal(
refund['totalRefundedSet']['presentmentMoney']['amount'])
line2warehouses = defaultdict(set)
shopify_fulfillment_orders = graphql.iterate(
shop, 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
if fulfillment_order['status'] != 'CANCELLED':
method_type = fulfillment_order['deliveryMethod']['methodType']
if method_type == 'PICK_UP':
shipment_address = sale.warehouse.address
shopify_line_items = graphql.iterate(
shop, 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)
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 and not sale.invoice_address:
address = Address(party=party)
address.save()
sale.invoice_address = address
sale._set_header_from_shopify(shop, order)
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 l.type == 'line'
and not l.shopify_identifier
and l.quantity > 0]
refund_lines = [
l for l in getattr(sale, 'lines', [])
if l.type == 'line'
and not l.shopify_identifier
and l.quantity < 0]
lines = []
shopify_line_items = graphql.iterate(
shop, 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(
shop, 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)
if refund_lines:
line = refund_lines[0]
else:
line = None
if refunded_amount or line:
line = Line.get_from_shopify_refund(
sale, refunded_amount, line=line)
lines.append(line)
if len(refund_lines) > 1:
for line in refund_lines[1:]:
line.quantity = 0
lines.append(line)
for line in id2line.values():
line.quantity = 0
lines.append(line)
sale.lines = lines
setattr_changed(
sale, 'shopify_amount_to_pay',
cls._shopify_amount_to_pay(order))
return sale
def _set_header_from_shopify(self, shop, order):
pass
@classmethod
def _shopify_amount_to_pay(cls, order):
if order['paymentTerms']:
return 0
@property
def amount_to_pay(self):
amount = super().amount_to_pay
if self.shopify_amount_to_pay is not None:
amount = self.shopify_amount_to_pay
return amount
@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()
shopify_request = self.web_shop.shopify_request
for shipment in self.shipments:
fulfillment = shipment.get_shopify(self)
if fulfillment:
try:
fulfillment = shopify_request(
MUTATION_FULFILLMENT_CREATE,
{'fulfillment': fulfillment},
user_errors='fulfillmentCreate.userErrors',
).data['fulfillmentCreate']['fulfillment']
except GraphQLException as e:
raise ShopifyError(gettext(
'web_shop_shopify.msg_fulfillment_fail',
sale=self.rec_name,
error=e.message)) 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)
self.web_shop.shopify_request(
MUTATION_FULFILLMENT_CANCEL,
{'id': fulfillment_id},
user_errors='fulfillmentCancel.userErrors')
# 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:
shopify_request(
MUTATION_REFUND_CREATE,
{'input': refund},
user_errors='refundCreate.userErrors')
except GraphQLException as e:
raise ShopifyError(gettext(
'web_shop_shopify.msg_refund_fail',
sale=self.rec_name,
error=e.message)) from e
order = shopify_request(
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_request(
QUERY_ORDER_CLOSED, {'id': shopify_id}).data['order']
if self.state == 'done':
if not order['closed']:
shopify_request(
MUTATION_ORDER_CLOSE, {
'input': {
'id': shopify_id,
},
},
user_errors='orderClose.userErrors')
elif order['closed']:
shopify_request(
MUTATION_ORDER_OPEN, {
'input': {
'id': shopify_id,
},
},
user_errors='orderOpen.userErrors')
def get_shopify_refund(self, shipping=False):
order_id = id2gid('Order', self.shopify_identifier)
shopify_line_items = graphql.iterate(
self.web_shop, 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 = self.web_shop.shopify_request(
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
def _set_header_from_shopify(self, shop, order):
super()._set_header_from_shopify(shop, order)
if (self.shipment_address
and self.shipment_address == self.warehouse.address):
shipment_cost_method = None
else:
shipment_cost_method = 'order'
carrier = None
if shipment_cost_method and (shipping_line := order['shippingLine']):
available_carriers = self.on_change_with_available_carriers()
for carrier in available_carriers:
if carrier.shopify_match(shop, shipping_line):
carrier = carrier
break
else:
if available_carriers:
carrier = available_carriers[0]
if not carrier:
shipment_cost_method = None
setattr_changed(self, 'carrier', carrier)
setattr_changed(self, 'shipment_cost_method', shipment_cost_method)
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
@classmethod
def get_from_shopify_refund(cls, sale, amount, 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_refund_product(sale)
setattr_changed(line, 'quantity', -1)
if line._changed_values:
line.on_change_product()
unit_price = round_price(Tax.reverse_compute(
amount, line.taxes, sale.sale_date))
setattr_changed(line, 'unit_price', unit_price)
return line
def _set_shopify_refund_product(self, sale):
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