Sales order barcode allocate (#6072)

* Bug fix for BarcodePOReceive endpoint

- Existing scan must match "stockitem" to raise an error

* bug fix: barcode.js

- Handle new return data from barcode scan endpoint

* Add barcode endpoint for allocating stock to sales order

* Improve logic for preventing over allocation of stock item to sales order

* Test for sufficient quantity

* Bump API version

* Bug fix and extra check

* Cleanup unit tests

* Add unit testing for new endpoint

* Add blank page for app sales orders docs

* Add docs for new barcode features in app

* Fix unit tests

* Remove debug statement
This commit is contained in:
Oliver 2023-12-14 11:13:50 +11:00 committed by GitHub
parent 3410534f29
commit 99c92ff655
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 569 additions and 52 deletions

View File

@ -2,11 +2,14 @@
# InvenTree API version
INVENTREE_API_VERSION = 159
INVENTREE_API_VERSION = 160
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v160 -> 2023-012-11 : https://github.com/inventree/InvenTree/pull/6072
- Adds API endpoint for allocating stock items against a sales order via barcode scan
v159 -> 2023-12-08 : https://github.com/inventree/InvenTree/pull/6056
- Adds API endpoint for reloading plugin registry

View File

@ -1645,8 +1645,17 @@ class SalesOrderAllocation(models.Model):
if self.quantity > self.item.quantity:
errors['quantity'] = _('Allocation quantity cannot exceed stock quantity')
# TODO: The logic here needs improving. Do we need to subtract our own amount, or something?
if self.item.quantity - self.item.allocation_count() + self.quantity < self.quantity:
# Ensure that we do not 'over allocate' a stock item
build_allocation_count = self.item.build_allocation_count()
sales_allocation_count = self.item.sales_order_allocation_count(
exclude_allocations={
"pk": self.pk,
}
)
total_allocation = build_allocation_count + sales_allocation_count + self.quantity
if total_allocation > self.item.quantity:
errors['quantity'] = _('Stock item is over-allocated')
if self.quantity <= 0:

View File

@ -137,6 +137,37 @@ class SalesOrderTest(TestCase):
quantity=25 if full else 20
)
def test_over_allocate(self):
"""Test that over allocation logic works"""
SA = StockItem.objects.create(part=self.part, quantity=9)
# First three allocations should succeed
for _i in range(3):
allocation = SalesOrderAllocation.objects.create(
line=self.line,
item=SA,
quantity=3,
shipment=self.shipment
)
# Editing an existing allocation with a larger quantity should fail
with self.assertRaises(ValidationError):
allocation.quantity = 4
allocation.save()
allocation.clean()
# Next allocation should fail
with self.assertRaises(ValidationError):
allocation = SalesOrderAllocation.objects.create(
line=self.line,
item=SA,
quantity=3,
shipment=self.shipment
)
allocation.clean()
def test_allocate_partial(self):
"""Partially allocate stock"""
self.allocate_stock(False)

View File

@ -2,6 +2,7 @@
import logging
from django.db.models import F
from django.urls import path, re_path
from django.utils.translation import gettext_lazy as _
@ -10,6 +11,8 @@ from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.generics import CreateAPIView
from rest_framework.response import Response
import order.models
import stock.models
from InvenTree.helpers import hash_barcode
from plugin import registry
from plugin.builtin.barcodes.inventree_barcode import \
@ -272,6 +275,10 @@ class BarcodePOAllocate(BarcodeView):
- A SupplierPart object
"""
role_required = [
'purchase_order.add'
]
serializer_class = barcode_serializers.BarcodePOAllocateSerializer
def get_supplier_part(self, purchase_order, part=None, supplier_part=None, manufacturer_part=None):
@ -370,6 +377,10 @@ class BarcodePOReceive(BarcodeView):
- location: The destination location for the received item (optional)
"""
role_required = [
'purchase_order.add'
]
serializer_class = barcode_serializers.BarcodePOReceiveSerializer
def handle_barcode(self, barcode: str, request, **kwargs):
@ -385,19 +396,26 @@ class BarcodePOReceive(BarcodeView):
# Look for a barcode plugin which knows how to deal with this barcode
plugin = None
response = {}
response = {
"barcode_data": barcode,
"barcode_hash": hash_barcode(barcode)
}
internal_barcode_plugin = next(filter(
lambda plugin: plugin.name == "InvenTreeBarcode", plugins
))
if internal_barcode_plugin.scan(barcode):
response["error"] = _("Item has already been received")
raise ValidationError(response)
if result := internal_barcode_plugin.scan(barcode):
if 'stockitem' in result:
response["error"] = _("Item has already been received")
raise ValidationError(response)
# Now, look just for "supplier-barcode" plugins
plugins = registry.with_mixin("supplier-barcode")
plugin_response = None
for current_plugin in plugins:
result = current_plugin.scan_receive_item(
@ -413,17 +431,18 @@ class BarcodePOReceive(BarcodeView):
if "error" in result:
logger.info("%s.scan_receive_item(...) returned an error: %s",
current_plugin.__class__.__name__, result["error"])
if not response:
if not plugin_response:
plugin = current_plugin
response = result
plugin_response = result
else:
plugin = current_plugin
response = result
plugin_response = result
break
response["plugin"] = plugin.name if plugin else None
response["barcode_data"] = barcode
response["barcode_hash"] = hash_barcode(barcode)
response['plugin'] = plugin.name if plugin else None
if plugin_response:
response = {**response, **plugin_response}
# A plugin has not been found!
if plugin is None:
@ -435,6 +454,158 @@ class BarcodePOReceive(BarcodeView):
return Response(response)
class BarcodeSOAllocate(BarcodeView):
"""Endpoint for allocating stock to a sales order, by scanning barcode.
The scanned barcode should map to a StockItem object.
Additional fields can be passed to the endpoint:
- SalesOrder (Required)
- Line Item
- Shipment
- Quantity
"""
role_required = [
'sales_order.add',
]
serializer_class = barcode_serializers.BarcodeSOAllocateSerializer
def get_line_item(self, stock_item, **kwargs):
"""Return the matching line item for the provided stock item"""
# Extract sales order object (required field)
sales_order = kwargs['sales_order']
# Next, check if a line-item is provided (optional field)
if line_item := kwargs.get('line', None):
return line_item
# If not provided, we need to find the correct line item
parts = stock_item.part.get_ancestors(include_self=True)
# Find any matching line items for the stock item
lines = order.models.SalesOrderLineItem.objects.filter(
order=sales_order,
part__in=parts,
shipped__lte=F('quantity'),
)
if lines.count() > 1:
raise ValidationError({
'error': _('Multiple matching line items found'),
})
if lines.count() == 0:
raise ValidationError({
'error': _('No matching line item found'),
})
return lines.first()
def get_shipment(self, **kwargs):
"""Extract the shipment from the provided kwargs, or guess"""
sales_order = kwargs['sales_order']
if shipment := kwargs.get('shipment', None):
if shipment.order != sales_order:
raise ValidationError({
'error': _('Shipment does not match sales order'),
})
return shipment
shipments = order.models.SalesOrderShipment.objects.filter(
order=sales_order,
delivery_date=None
)
if shipments.count() == 1:
return shipments.first()
# If shipment cannot be determined, return None
return None
def handle_barcode(self, barcode: str, request, **kwargs):
"""Handle barcode scan for sales order allocation."""
logger.debug("BarcodeSOAllocate: scanned barcode - '%s'", barcode)
result = self.scan_barcode(barcode, request, **kwargs)
if result['plugin'] is None:
result['error'] = _('No match found for barcode data')
raise ValidationError(result)
# Check that the scanned barcode was a StockItem
if 'stockitem' not in result:
result['error'] = _('Barcode does not match an existing stock item')
raise ValidationError(result)
try:
stock_item_id = result['stockitem'].get('pk', None)
stock_item = stock.models.StockItem.objects.get(pk=stock_item_id)
except (ValueError, stock.models.StockItem.DoesNotExist):
result['error'] = _('Barcode does not match an existing stock item')
raise ValidationError(result)
# At this stage, we have a valid StockItem object
# Extract any other data from the kwargs
line_item = self.get_line_item(stock_item, **kwargs)
sales_order = kwargs['sales_order']
shipment = self.get_shipment(**kwargs)
if stock_item is not None and line_item is not None:
if stock_item.part != line_item.part:
result['error'] = _('Stock item does not match line item')
raise ValidationError(result)
quantity = kwargs.get('quantity', None)
# Override quantity for serialized items
if stock_item.serialized:
quantity = 1
if quantity is None:
quantity = line_item.quantity - line_item.shipped
quantity = min(quantity, stock_item.unallocated_quantity())
response = {
'stock_item': stock_item.pk if stock_item else None,
'part': stock_item.part.pk if stock_item else None,
'sales_order': sales_order.pk if sales_order else None,
'line_item': line_item.pk if line_item else None,
'shipment': shipment.pk if shipment else None,
'quantity': quantity
}
if stock_item is not None and quantity is not None:
if stock_item.unallocated_quantity() < quantity:
response['error'] = _('Insufficient stock available')
raise ValidationError(response)
# If we have sufficient information, we can allocate the stock item
if all((x is not None for x in [line_item, sales_order, shipment, quantity])):
order.models.SalesOrderAllocation.objects.create(
line=line_item,
shipment=shipment,
item=stock_item,
quantity=quantity,
)
response['success'] = _('Stock item allocated to sales order')
return Response(response)
response['error'] = _('Not enough information')
response['action_required'] = True
raise ValidationError(response)
barcode_api_urls = [
# Link a third-party barcode to an item (e.g. Part / StockItem / etc)
path('link/', BarcodeAssign.as_view(), name='api-barcode-link'),
@ -448,6 +619,9 @@ barcode_api_urls = [
# Allocate parts to a purchase order by scanning their barcode
path("po-allocate/", BarcodePOAllocate.as_view(), name="api-barcode-po-allocate"),
# Allocate stock to a sales order by scanning barcode
path("so-allocate/", BarcodeSOAllocate.as_view(), name="api-barcode-so-allocate"),
# Catch-all performs barcode 'scan'
re_path(r'^.*$', BarcodeScan.as_view(), name='api-barcode-scan'),
]

View File

@ -7,7 +7,7 @@ from rest_framework import serializers
import order.models
import stock.models
from InvenTree.status_codes import PurchaseOrderStatus
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
from plugin.builtin.barcodes.inventree_barcode import \
InvenTreeInternalBarcodePlugin
@ -78,7 +78,7 @@ class BarcodePOAllocateSerializer(BarcodeSerializer):
purchase_order = serializers.PrimaryKeyRelatedField(
queryset=order.models.PurchaseOrder.objects.all(),
required=True,
help_text=_('PurchaseOrder to allocate items against'),
help_text=_('Purchase Order to allocate items against'),
)
def validate_purchase_order(self, order: order.models.PurchaseOrder):
@ -126,3 +126,49 @@ class BarcodePOReceiveSerializer(BarcodeSerializer):
raise ValidationError(_("Cannot select a structural location"))
return location
class BarcodeSOAllocateSerializer(BarcodeSerializer):
"""Serializr for allocating stock items to a sales order
The scanned barcode must map to a StockItem object
"""
sales_order = serializers.PrimaryKeyRelatedField(
queryset=order.models.SalesOrder.objects.all(),
required=True,
help_text=_('Sales Order to allocate items against'),
)
def validate_sales_order(self, order: order.models.SalesOrder):
"""Validate the provided order"""
if order and order.status != SalesOrderStatus.PENDING.value:
raise ValidationError(_("Sales order is not pending"))
return order
line = serializers.PrimaryKeyRelatedField(
queryset=order.models.SalesOrderLineItem.objects.all(),
required=False, allow_null=True,
help_text=_('Sales order line item to allocate items against'),
)
shipment = serializers.PrimaryKeyRelatedField(
queryset=order.models.SalesOrderShipment.objects.all(),
required=False, allow_null=True,
help_text=_('Sales order shipment to allocate items against'),
)
def validate_shipment(self, shipment: order.models.SalesOrderShipment):
"""Validate the provided shipment"""
if shipment and shipment.is_delivered():
raise ValidationError(_("Shipment has already been delivered"))
return shipment
quantity = serializers.IntegerField(
required=False,
help_text=_('Quantity to allocate'),
)

View File

@ -2,6 +2,8 @@
from django.urls import reverse
import company.models
import order.models
from InvenTree.unit_test import InvenTreeAPITestCase
from part.models import Part
from stock.models import StockItem
@ -257,3 +259,175 @@ class BarcodeAPITest(InvenTreeAPITestCase):
)
self.assertIn("object does not exist", str(response.data[k]))
class SOAllocateTest(InvenTreeAPITestCase):
"""Unit tests for the barcode endpoint for allocating items to a sales order"""
fixtures = [
'category',
'company',
'part',
'location',
'stock',
]
@classmethod
def setUpTestData(cls):
"""Setup for all tests."""
super().setUpTestData()
# Assign required roles
cls.assignRole('sales_order.change')
cls.assignRole('sales_order.add')
# Find a salable part
cls.part = Part.objects.filter(salable=True).first()
# Make a stock item
cls.stock_item = StockItem.objects.create(
part=cls.part,
quantity=100
)
cls.stock_item.assign_barcode(barcode_data='barcode')
# Find a customer
cls.customer = company.models.Company.objects.filter(
is_customer=True
).first()
# Create a sales order
cls.sales_order = order.models.SalesOrder.objects.create(
customer=cls.customer
)
# Create a shipment
cls.shipment = order.models.SalesOrderShipment.objects.create(
order=cls.sales_order
)
# Create a line item
cls.line_item = order.models.SalesOrderLineItem.objects.create(
order=cls.sales_order,
part=cls.part,
quantity=10,
)
def setUp(self):
"""Setup method for each test"""
super().setUp()
def postBarcode(self, barcode, expected_code=None, **kwargs):
"""Post barcode and return results."""
data = {
'barcode': barcode,
**kwargs
}
response = self.post(
reverse('api-barcode-so-allocate'),
data=data,
expected_code=expected_code,
)
return response.data
def test_no_data(self):
"""Test when no data is provided"""
result = self.postBarcode('', expected_code=400)
self.assertIn('This field may not be blank', str(result['barcode']))
self.assertIn('This field is required', str(result['sales_order']))
def test_invalid_sales_order(self):
"""Test when an invalid sales order is provided"""
# Test with an invalid sales order ID
result = self.postBarcode(
'',
sales_order=999999999,
expected_code=400
)
self.assertIn('object does not exist', str(result['sales_order']))
def test_invalid_barcode(self):
"""Test when an invalid barcode is provided (does not match stock item)"""
# Test with an invalid barcode
result = self.postBarcode(
'123456789',
sales_order=self.sales_order.pk,
expected_code=400
)
self.assertIn('No match found for barcode', str(result['error']))
# Test with a barcode that matches a *different* stock item
item = StockItem.objects.exclude(pk=self.stock_item.pk).first()
item.assign_barcode(barcode_data='123456789')
result = self.postBarcode(
'123456789',
sales_order=self.sales_order.pk,
expected_code=400
)
self.assertIn('No matching line item found', str(result['error']))
# Test with barcode which points to a *part* instance
item.part.assign_barcode(barcode_data='abcde')
result = self.postBarcode(
'abcde',
sales_order=self.sales_order.pk,
expected_code=400
)
self.assertIn('does not match an existing stock item', str(result['error']))
def test_submit(self):
"""Test data submission"""
# Create a shipment for a different order
other_order = order.models.SalesOrder.objects.create(
customer=self.customer
)
other_shipment = order.models.SalesOrderShipment.objects.create(
order=other_order
)
# Test with invalid shipment
response = self.postBarcode(
self.stock_item.format_barcode(),
sales_order=self.sales_order.pk,
shipment=other_shipment.pk,
expected_code=400
)
self.assertIn('Shipment does not match sales order', str(response['error']))
# No stock has been allocated
self.assertEqual(self.line_item.allocated_quantity(), 0)
# Test with minimum valid data - this should be enough information to allocate stock
response = self.postBarcode(
self.stock_item.format_barcode(),
sales_order=self.sales_order.pk,
expected_code=200
)
# Check that the right data has been extracted
self.assertIn('Stock item allocated', str(response['success']))
self.assertEqual(response['sales_order'], self.sales_order.pk)
self.assertEqual(response['line_item'], self.line_item.pk)
self.assertEqual(response['shipment'], self.shipment.pk)
self.assertEqual(response['quantity'], 10)
self.line_item.refresh_from_db()
self.assertEqual(self.line_item.allocated_quantity(), 10)
self.assertTrue(self.line_item.is_fully_allocated())

View File

@ -139,33 +139,45 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
part = Part.objects.create(name="Test Part", description="Test Part")
supplier = Company.objects.create(name="Supplier", is_supplier=True)
manufacturer = Company.objects.create(
name="Test Manufacturer", is_manufacturer=True)
name="Test Manufacturer", is_manufacturer=True
)
mouser = Company.objects.create(name="Mouser Test", is_supplier=True)
mpart = ManufacturerPart.objects.create(
part=part, manufacturer=manufacturer, MPN="MC34063ADR")
part=part, manufacturer=manufacturer, MPN="MC34063ADR"
)
self.purchase_order1 = PurchaseOrder.objects.create(
supplier_reference="72991337", supplier=supplier)
supplier_reference="72991337", supplier=supplier
)
supplier_parts1 = [
SupplierPart(SKU=f"1_{i}", part=part, supplier=supplier)
for i in range(6)
]
supplier_parts1.insert(
2, SupplierPart(SKU="296-LM358BIDDFRCT-ND", part=part, supplier=supplier))
2, SupplierPart(SKU="296-LM358BIDDFRCT-ND", part=part, supplier=supplier)
)
for supplier_part in supplier_parts1:
supplier_part.save()
self.purchase_order1.add_line_item(supplier_part, 8)
self.purchase_order2 = PurchaseOrder.objects.create(
reference="P0-1337", supplier=mouser)
reference="P0-1337", supplier=mouser
)
self.purchase_order2.place_order()
supplier_parts2 = [
SupplierPart(SKU=f"2_{i}", part=part, supplier=mouser)
for i in range(6)
]
supplier_parts2.insert(
3, SupplierPart(SKU="42", part=part, manufacturer_part=mpart, supplier=mouser))
supplier_parts2.insert(3, SupplierPart(
SKU="42", part=part, manufacturer_part=mpart, supplier=mouser
))
for supplier_part in supplier_parts2:
supplier_part.save()
self.purchase_order2.add_line_item(supplier_part, 5)
@ -175,28 +187,23 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
url = reverse("api-barcode-po-receive")
result1 = self.post(url, data={"barcode": DIGIKEY_BARCODE})
assert result1.status_code == 400
result1 = self.post(url, data={"barcode": DIGIKEY_BARCODE}, expected_code=400)
assert result1.data["error"].startswith("No matching purchase order")
self.purchase_order1.place_order()
result2 = self.post(url, data={"barcode": DIGIKEY_BARCODE})
assert result2.status_code == 200
assert "success" in result2.data
result2 = self.post(url, data={"barcode": DIGIKEY_BARCODE}, expected_code=200)
self.assertIn("success", result2.data)
result3 = self.post(url, data={"barcode": DIGIKEY_BARCODE})
assert result3.status_code == 400
assert result3.data["error"].startswith(
"Item has already been received")
result3 = self.post(url, data={"barcode": DIGIKEY_BARCODE}, expected_code=400)
self.assertEqual(result3.data['error'], "Item has already been received")
result4 = self.post(url, data={"barcode": DIGIKEY_BARCODE[:-1]})
assert result4.status_code == 400
result4 = self.post(url, data={"barcode": DIGIKEY_BARCODE[:-1]}, expected_code=400)
assert result4.data["error"].startswith(
"Failed to find pending line item for supplier part")
result5 = self.post(reverse("api-barcode-scan"), data={"barcode": DIGIKEY_BARCODE})
assert result5.status_code == 200
result5 = self.post(reverse("api-barcode-scan"), data={"barcode": DIGIKEY_BARCODE}, expected_code=200)
stock_item = StockItem.objects.get(pk=result5.data["stockitem"]["pk"])
assert stock_item.supplier_part.SKU == "296-LM358BIDDFRCT-ND"
assert stock_item.quantity == 10

View File

@ -1106,7 +1106,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo
return total
def get_sales_order_allocations(self, active=True):
def get_sales_order_allocations(self, active=True, **kwargs):
"""Return a queryset for SalesOrderAllocations against this StockItem, with optional filters.
Arguments:
@ -1114,6 +1114,12 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo
"""
query = self.sales_order_allocations.all()
if filter_allocations := kwargs.get('filter_allocations', None):
query = query.filter(**filter_allocations)
if exclude_allocations := kwargs.get('exclude_allocations', None):
query = query.exclude(**exclude_allocations)
if active is True:
query = query.filter(
line__order__status__in=SalesOrderStatusGroups.OPEN,
@ -1128,9 +1134,9 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo
return query
def sales_order_allocation_count(self, active=True):
def sales_order_allocation_count(self, active=True, **kwargs):
"""Return the total quantity allocated to SalesOrders."""
query = self.get_sales_order_allocations(active=active)
query = self.get_sales_order_allocations(active=active, **kwargs)
query = query.aggregate(q=Coalesce(Sum('quantity'), Decimal(0)))
total = query['q']

View File

@ -419,6 +419,18 @@ function barcodeScanDialog(options={}) {
let modal = options.modal || createNewModal();
let title = options.title || '{% trans "Scan Barcode" %}';
const matching_models = [
'build',
'manufacturerpart',
'part',
'purchaseorder',
'returnorder',
'salesorder',
'supplierpart',
'stockitem',
'stocklocation'
];
barcodeDialog(
title,
{
@ -428,19 +440,24 @@ function barcodeScanDialog(options={}) {
if (options.onScan) {
options.onScan(response);
} else {
// Find matching model
matching_models.forEach(function(model) {
if (model in response) {
let instance = response[model];
let url = instance.web_url || instance.url;
if (url) {
window.location.href = url;
return;
}
}
});
let url = response.url;
if (url) {
$(modal).modal('hide');
window.location.href = url;
} else {
showBarcodeMessage(
modal,
'{% trans "No URL in response" %}',
'warning'
);
}
// No match
showBarcodeMessage(
modal,
'{% trans "No URL in response" %}',
'warning'
);
}
}
},

View File

@ -112,3 +112,15 @@ From the [Purchase Order detail page](./po.md#purchase-order-detail) page, the f
#### Scan Received Parts
Receive incoming purchase order items against the selected purchase order. Scanning a *new* barcode which is associated with an item in an incoming purchase order will receive the item into stock.
### Sales Order Actions
The following barcode actions are available for [Sales Orders](./so.md):
#### Add Line Item
Add a new line item to the selected order by scanning a *Part* barcode
#### Assign Stock
Allocate stock items to the selected sales order by scanning a *StockItem* barcode

View File

@ -4,7 +4,7 @@ title: Purchase Orders
## Purchase Order List
The purchase order list display shows all current *outstanding* purchase orders. (Purchase orders which have been completed are not shown here).
The purchase order list display lists all purchase orders:
{% with id="po_list", url="app/po_list.png", maxheight="240px", description="Purchase order list" %}
{% include "img.html" %}
@ -14,7 +14,7 @@ Select an individual purchase order to display the detail view for that order.
### Filtering
Available purchase orders can be subsequently filtered using the search input at the top of the screen
Displayed purchase orders can be subsequently filtered using the search input at the top of the screen
## Purchase Order Detail

37
docs/docs/app/so.md Normal file
View File

@ -0,0 +1,37 @@
---
title: Sales Orders
---
## Sales Order List
The sales order list display shows all sales orders:
{% with id="so_list", url="app/so_list.png", maxheight="240px", description="Sales order list" %}
{% include "img.html" %}
{% endwith %}
Select an individual sales order to display the detail view for that order.
### Filtering
Displayed sales orders can be subsequently filtered using the search input at the top of the screen
## Sales Order Detail
Select an individual order to show the detailed view for that order:
{% with id="so_detail", url="app/so_detail.png", maxheight="240px", description="Sales order details" %}
{% include "img.html" %}
{% endwith %}
### Edit Order Details
From the detail view, select the *Edit* button in the top-right of the screen. This opens the sales order editing display.
### Line Items
View the line items associated with the selected order:
{% with id="so_lines", url="app/so_lines.png", maxheight="240px", description="Sales order lines" %}
{% include "img.html" %}
{% endwith %}

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

View File

@ -174,6 +174,7 @@ nav:
- Parts: app/part.md
- Stock: app/stock.md
- Purchase Orders: app/po.md
- Sales Orders: app/so.md
- Settings: app/settings.md
- Privacy: app/privacy.md
- Translation: app/translation.md