Skip to content

Commit

Permalink
Merge pull request #115 from miggland/sales-order-shipment
Browse files Browse the repository at this point in the history
Add sales order shipment
  • Loading branch information
SchrodingersGat committed Jul 25, 2022
2 parents 912f646 + f1bad90 commit a5c4fd0
Show file tree
Hide file tree
Showing 3 changed files with 283 additions and 1 deletion.
159 changes: 159 additions & 0 deletions inventree/order.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,20 @@ def uploadAttachment(self, attachment, comment=''):
comment=comment,
order=self.pk,
)

def getShipments(self, **kwargs):
""" Return the shipments associated with this order """

return SalesOrderShipment.list(self._api, order=self.pk, **kwargs)

def addShipment(self, reference, **kwargs):
""" Create (and return) new SalesOrderShipment
against this SalesOrder """

kwargs['order'] = self.pk
kwargs['reference'] = reference

return SalesOrderShipment.create(self._api, data=kwargs)


class SalesOrderLineItem(inventree.base.InventreeObject):
Expand All @@ -152,6 +166,75 @@ def getOrder(self):
"""
return SalesOrder(self._api, self.order)

def allocateToShipment(self, shipment, stockitems=None, quantity=None):
"""
Assign the items of this line to the given shipment.
By default, assign the total quantity using the first stock
item(s) found. As many items as possible, up to the quantity in
sales order, are assigned.
To limit which stock items can be used, supply a list of stockitems
to use in the argument stockitems.
To limit how many items are assigned, supply a quantity to the
argument quantity. This can also be used to over-assign the items,
as no check for the amounts in the sales order is performed.
This function returns a list of items assigned during this call.
If nothing is returned, this means that nothing was assigned,
possibly because no stock items are available.
"""

# If stockitems are not defined, get the list
if stockitems is None:
stockitems = self.getPart().getStockItems()

# If no quantity is defined, calculate the number of required items
# This is the number of sold items not yet allocated, but can not
# be higher than the number of allocated items
if quantity is None:
required_amount = min(
self.quantity - self.allocated, self.available_stock
)

else:
try:
required_amount = int(quantity)
except ValueError:
raise ValueError(
"Argument quantity must be convertible to an integer"
)

# Look through stock items, assign items until the required amount
# is reached
items = list()
for SI in stockitems:

# Check if we are done
if required_amount <= 0:
continue

# Check that this item has available stock
if SI.quantity - SI.allocated > 0:
thisitem = {
"line_item": self.pk,
"quantity": min(
required_amount, SI.quantity - SI.allocated
),
"stock_item": SI.pk
}

# Correct the required amount
required_amount -= thisitem["quantity"]

# Append
items.append(thisitem)

# Use SalesOrderShipment method to perform allocation
if len(items) > 0:
return shipment.allocateItems(items)


class SalesOrderExtraLineItem(inventree.base.InventreeObject):
""" Class representing the SalesOrderExtraLineItem database model """
Expand All @@ -171,3 +254,79 @@ class SalesOrderAttachment(inventree.base.Attachment):
URL = 'order/so/attachment'

REQUIRED_KWARGS = ['order']


class SalesOrderShipment(inventree.base.InventreeObject):
"""Class representing a shipment for a SalesOrder"""

URL = 'order/so/shipment'

def getOrder(self):
"""
Return the SalesOrder to which this SalesOrderShipment belongs
"""
return SalesOrder(self._api, self.order)

def allocateItems(self, items=[]):
"""
Function to allocate items to the current shipment
items is expected to be a list containing dicts, one for each item
to be assigned. Each dict should contain three parameters, as
follows:
items = [{
"line_item": 25,
"quantity": 150,
"stock_item": 26
}
"""

# Customise URL
url = f'order/so/{self.getOrder().pk}/allocate'

# Create data from given inputs
data = {
'items': items,
'shipment': self.pk
}

# Send data
response = self._api.post(url, data)

# Reload
self.reload()

# Return
return response

def complete(
self,
shipment_date=None,
tracking_number='',
invoice_number='',
link=''
):
"""
Complete the shipment, with given shipment_date, or reasonable
defaults.
"""

# Customise URL
url = f'order/so/shipment/{self.pk}/ship'

# Create data from given inputs
data = {
'shipment_date': shipment_date,
'tracking_number': tracking_number,
'invoice_number': invoice_number,
'link': link
}

# Send data
response = self._api.post(url, data)

# Reload
self.reload()

# Return
return response
123 changes: 123 additions & 0 deletions test/test_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,3 +349,126 @@ def test_so_attachment(self):

attachments = order.SalesOrderAttachment.list(self.api, order=so.pk)
self.assertEqual(len(attachments), n + 1)

def test_so_shipment(self):
"""
Test shipment functionality for a SalesOrder
"""

# Grab the last available SalesOrder - should not have a shipment yet
orders = order.SalesOrder.list(self.api)

if len(orders) > 0:
so = orders[-1]
else:
so = order.SalesOrder.create(self.api, {
'customer': 4,
'reference': "My new sales order",
"description": "Selling some stuff",
})

# The shipments list should return something which is not none
self.assertIsNotNone(so.getShipments())

# Count number of current shipments
num_shipments = len(so.getShipments())

# Create a new shipment - without data, use SalesOrderShipment method
with self.assertRaises(TypeError):
shipment_1 = order.SalesOrderShipment.create(self.api)

# Create new shipment - minimal data, use SalesOrderShipment method
shipment_1 = order.SalesOrderShipment.create(
self.api, data={
'order': so.pk,
'reference': f'Package {num_shipments+1}'
}
)

# Assert the shipment is created
self.assertIsNotNone(shipment_1)

# Assert the shipment Order is equal to the expected one
self.assertEqual(shipment_1.getOrder().pk, so.pk)

# Count number of current shipments
self.assertEqual(len(so.getShipments()), num_shipments + 1)
num_shipments = len(so.getShipments())

# Create new shipment - use addShipment method.
# Should fail because reference will not be unique
with self.assertRaises(HTTPError):
shipment_2 = so.addShipment(f'Package {num_shipments}')

# Create new shipment - use addShipment method. No extra data
shipment_2 = so.addShipment(f'Package {num_shipments+1}')

# Assert the shipment is not created
self.assertIsNotNone(shipment_2)

# Assert the shipment Order is equal to the expected one
self.assertEqual(shipment_2.getOrder().pk, so.pk)

# Assert shipment reference is as expected
self.assertEqual(shipment_2.reference, f'Package {num_shipments+1}')

# Count number of current shipments
self.assertEqual(len(so.getShipments()), num_shipments + 1)
num_shipments = len(so.getShipments())

# Create another shipment - use addShipment method.
# With some extra data, including non-sense order
# (which should be overwritten)
notes = f'Test shipment number {num_shipments+1} for order {so.pk}'
tracking_number = '93414134343'
shipment_2 = so.addShipment(
reference=f'Package {num_shipments+1}',
order=10103413,
notes=notes,
tracking_number=tracking_number
)

# Assert the shipment is created
self.assertIsNotNone(shipment_2)

# Assert the shipment Order is equal to the expected one
self.assertEqual(shipment_2.getOrder().pk, so.pk)

# Assert shipment reference is as expected
self.assertEqual(shipment_2.reference, f'Package {num_shipments+1}')

# Make sure extra data is also as expected
self.assertEqual(shipment_2.notes, notes)
self.assertEqual(shipment_2.tracking_number, tracking_number)

# Count number of current shipments
self.assertEqual(len(so.getShipments()), num_shipments + 1)
num_shipments = len(so.getShipments())

# Remember for later test
allocated_quantities = dict()

# Assign each line item to this shipment
for si in so.getLineItems():
response = si.allocateToShipment(shipment_2)
# Remember what we are doing for later check
# a response of None means nothing was allocated
if response is not None:
allocated_quantities[si.pk] = (
{x['stock_item']: float(x['quantity']) for x in response['items']}
)

# Check saved values
for so_part in so.getLineItems():
if so_part.pk in allocated_quantities:
if len(allocated_quantities[so_part.pk]) > 0:
self.assertEqual(
{x['item']: float(x['quantity']) for x in shipment_2.allocations if x['line'] == so_part.pk},
allocated_quantities[so_part.pk]
)

# Complete the shipment, with minimum information
shipment_2.complete()

# Make sure date is not None
self.assertIsNotNone(shipment_2.shipment_date)
2 changes: 1 addition & 1 deletion test/test_stock.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def test_location_stock(self):

items = location.getStockItems()

self.assertGreaterEqual(len(items), 20)
self.assertGreaterEqual(len(items), 19)

# Check specific part stock in location 1 (initially empty)
items = location.getStockItems(part=1)
Expand Down

0 comments on commit a5c4fd0

Please sign in to comment.