Skip to content

Commit

Permalink
Purchase Order Destination (#8403)
Browse files Browse the repository at this point in the history
* Add "destination" field to PurchaseOrder

* Add 'destination' field to API

* Add location to PurchaseOrderDetail page

* Display "destination" on PurchaseOrderDetail page

* Pre-select location based on selected "destination"

* Fix order of reception priority

* Auto-expand the per-line destination field

* Add "Purchase Order" detail to StockItemDetail page

* Bug fix in PurchaseOrderForms

* Split playwright tests

* Docs updates

* Bump API version

* Unit test fixes

* Fix more tests

* Backport to CUI

* Use PurchaseOrder destination when scanning items
  • Loading branch information
SchrodingersGat authored Nov 1, 2024
1 parent 871cd90 commit c4031db
Show file tree
Hide file tree
Showing 20 changed files with 240 additions and 71 deletions.
8 changes: 8 additions & 0 deletions docs/docs/order/purchase_order.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ There are two options to mark items as "received":
!!! note "Permissions"
Marking line items as received requires the "Purchase order" ADD permission.

### Item Location

When receiving items from a purchase order, the location of the items must be specified. There are multiple ways to specify the location:

* **Order Destination**: The *destination* field of the purchase order can be set to a specific location. When receiving items, the location will default to the destination location.

* **Line Item Location**: Each line item can have a specific location set. When receiving items, the location will default to the line item location. *Note: A destination specified at the line item level will override the destination specified at the order level.*

### Received Items

Each item marked as "received" is automatically converted into a stock item.
Expand Down
7 changes: 5 additions & 2 deletions src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
"""InvenTree API version information."""

# InvenTree API version
INVENTREE_API_VERSION = 275
INVENTREE_API_VERSION = 276

"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""


INVENTREE_API_TEXT = """
v274 - 2024-10-31 : https://github.com/inventree/InvenTree/pull/8396
v276 - 2024-10-31 : https://github.com/inventree/InvenTree/pull/8403
- Adds 'destination' field to the PurchaseOrder model and API endpoints
v275 - 2024-10-31 : https://github.com/inventree/InvenTree/pull/8396
- Adds SKU and MPN fields to the StockItem serializer
- Additional export options for the StockItem serializer
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 4.2.16 on 2024-10-31 02:52

from django.db import migrations
import django.db.models.deletion
import mptt.fields


class Migration(migrations.Migration):

dependencies = [
('stock', '0113_stockitem_status_custom_key_and_more'),
('order', '0101_purchaseorder_status_custom_key_and_more'),
]

operations = [
migrations.AddField(
model_name='purchaseorder',
name='destination',
field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='purchase_orders', to='stock.stocklocation', verbose_name='Destination', help_text='Destination for received items'),
),
migrations.AlterField(
model_name='purchaseorderlineitem',
name='destination',
field=mptt.fields.TreeForeignKey(blank=True, help_text='Destination for received items', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='po_lines', to='stock.stocklocation', verbose_name='Destination'),
),
]
12 changes: 11 additions & 1 deletion src/backend/InvenTree/order/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,16 @@ def company(self):
help_text=_('Date order was completed'),
)

destination = TreeForeignKey(
'stock.StockLocation',
on_delete=models.SET_NULL,
related_name='purchase_orders',
blank=True,
null=True,
verbose_name=_('Destination'),
help_text=_('Destination for received items'),
)

@transaction.atomic
def add_line_item(
self,
Expand Down Expand Up @@ -1544,7 +1554,7 @@ def price(self):
related_name='po_lines',
blank=True,
null=True,
help_text=_('Where does the Purchaser want this item to be stored?'),
help_text=_('Destination for received items'),
)

def get_destination(self):
Expand Down
13 changes: 9 additions & 4 deletions src/backend/InvenTree/order/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ class Meta:
'supplier_name',
'total_price',
'order_currency',
'destination',
])

read_only_fields = ['issue_date', 'complete_date', 'creation_date']
Expand Down Expand Up @@ -860,6 +861,7 @@ class Meta:
location = serializers.PrimaryKeyRelatedField(
queryset=stock.models.StockLocation.objects.all(),
many=False,
required=False,
allow_null=True,
label=_('Location'),
help_text=_('Select destination location for received items'),
Expand All @@ -873,9 +875,10 @@ def validate(self, data):
"""
super().validate(data)

order = self.context['order']
items = data.get('items', [])

location = data.get('location', None)
location = data.get('location', order.destination)

if len(items) == 0:
raise ValidationError(_('Line items must be provided'))
Expand Down Expand Up @@ -919,15 +922,17 @@ def save(self):
order = self.context['order']

items = data['items']
location = data.get('location', None)

# Location can be provided, or default to the order destination
location = data.get('location', order.destination)

# Now we can actually receive the items into stock
with transaction.atomic():
for item in items:
# Select location (in descending order of priority)
loc = (
location
or item.get('location', None)
item.get('location', None)
or location
or item['line_item'].get_destination()
)

Expand Down
10 changes: 9 additions & 1 deletion src/backend/InvenTree/order/templates/order/order_base.html
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,15 @@
{% endif %}
</td>
</tr>

{% if order.destination %}
<tr>
<td><span class='fas fa-sitemap'></span></td>
<td>{% trans "Destination" %}</td>
<td>
<a href='{% url "stock-location-detail" order.destination.id %}'>{{ order.destination.name }}</a>
</td>
</tr>
{% endif %}
</table>

{% endblock details %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ <h4>{% trans "Order Notes" %}</h4>
{{ order.id }},
items,
{
{% if order.destination %}
destination: {{ order.destination.pk }},
{% endif %}
success: function() {
$("#po-line-table").bootstrapTable('refresh');
}
Expand Down
5 changes: 2 additions & 3 deletions src/backend/InvenTree/order/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -882,7 +882,6 @@ def test_empty(self):
data = self.post(self.url, {}, expected_code=400).data

self.assertIn('This field is required', str(data['items']))
self.assertIn('This field is required', str(data['location']))

# No new stock items have been created
self.assertEqual(self.n, StockItem.objects.count())
Expand Down Expand Up @@ -1060,9 +1059,9 @@ def test_valid(self):
self.assertEqual(stock_1.count(), 1)
self.assertEqual(stock_2.count(), 1)

# Same location for each received item, as overall 'location' field is provided
# Check received locations
self.assertEqual(stock_1.last().location.pk, 1)
self.assertEqual(stock_2.last().location.pk, 1)
self.assertEqual(stock_2.last().location.pk, 2)

# Barcodes should have been assigned to the stock items
self.assertTrue(
Expand Down
9 changes: 9 additions & 0 deletions src/backend/InvenTree/plugin/base/barcodes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,15 @@ def handle_barcode(self, barcode: str, request, **kwargs):
purchase_order = kwargs.get('purchase_order')
location = kwargs.get('location')

# Extract location from PurchaseOrder, if available
if not location and purchase_order:
try:
po = order.models.PurchaseOrder.objects.get(pk=purchase_order)
if po.destination:
location = po.destination.pk
except Exception:
pass

plugins = registry.with_mixin('barcode')

# Look for a barcode plugin which knows how to deal with this barcode
Expand Down
2 changes: 1 addition & 1 deletion src/backend/InvenTree/plugin/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def test_plugin_install(self):
'packagename': 'invalid_package_name-asdads-asfd-asdf-asdf-asdf',
},
expected_code=400,
max_query_time=30,
max_query_time=60,
)

# valid - Pypi
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ function purchaseOrderFields(options={}) {
target_date: {
icon: 'fa-calendar-alt',
},
destination: {
icon: 'fa-sitemap'
},
link: {
icon: 'fa-link',
},
Expand Down Expand Up @@ -1361,6 +1364,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
method: 'POST',
fields: {
location: {
value: options.destination,
filters: {
structural: false,
},
Expand Down
19 changes: 16 additions & 3 deletions src/frontend/src/forms/PurchaseOrderForms.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ export function usePurchaseOrderFields({
target_date: {
icon: <IconCalendar />
},
destination: {
filters: {
structural: false
}
},
link: {},
contact: {
icon: <IconUser />,
Expand Down Expand Up @@ -232,14 +237,21 @@ function LineItemFormRow({
onClose: () => props.changeFn(props.idx, 'location', undefined)
});

useEffect(() => {
if (!!record.destination) {
props.changeFn(props.idx, 'location', record.destination);
locationHandlers.open();
}
}, [record.destination]);

// Batch code generator
const batchCodeGenerator = useBatchCodeGenerator((value: any) => {
if (value) {
props.changeFn(props.idx, 'batch_code', value);
}
});

// Serial numbebr generator
// Serial number generator
const serialNumberGenerator = useSerialNumberGenerator((value: any) => {
if (value) {
props.changeFn(props.idx, 'serial_numbers', value);
Expand Down Expand Up @@ -475,7 +487,7 @@ function LineItemFormRow({
props.changeFn(props.idx, 'location', value);
},
description: locationDescription,
value: location,
value: props.item.location,
label: t`Location`,
icon: <InvenTreeIcon icon="location" />
}}
Expand Down Expand Up @@ -599,6 +611,7 @@ type LineFormHandlers = {
type LineItemsForm = {
items: any[];
orderPk: number;
destinationPk?: number;
formProps?: LineFormHandlers;
};

Expand Down Expand Up @@ -674,7 +687,7 @@ export function useReceiveLineItems(props: LineItemsForm) {
title: t`Receive Line Items`,
fields: fields,
initialData: {
location: null
location: props.destinationPk
},
size: '80%'
});
Expand Down
8 changes: 8 additions & 0 deletions src/frontend/src/pages/build/BuildDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,14 @@ export default function BuildDetail() {
label: t`Completed`,
icon: 'calendar',
hidden: !build.completion_date
},
{
type: 'text',
name: 'project_code_label',
label: t`Project Code`,
icon: 'reference',
copy: true,
hidden: !build.project_code
}
];

Expand Down
22 changes: 18 additions & 4 deletions src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,12 +153,19 @@ export default function PurchaseOrderDetail() {
total: order.line_items,
progress: order.completed_lines
},
{
type: 'link',
model: ModelType.stocklocation,
link: true,
name: 'destination',
label: t`Destination`,
hidden: !order.destination
},
{
type: 'text',
name: 'currency',
label: t`Order Currency`,
value_formatter: () =>
order?.order_currency ?? order?.supplier_detail?.currency
value_formatter: () => orderCurrency
},
{
type: 'text',
Expand Down Expand Up @@ -190,8 +197,15 @@ export default function PurchaseOrderDetail() {
icon: 'user',
copy: true,
hidden: !order.contact
},
{
type: 'text',
name: 'project_code_label',
label: t`Project Code`,
icon: 'reference',
copy: true,
hidden: !order.project_code
}
// TODO: Project code
];

let br: DetailsField[] = [
Expand Down Expand Up @@ -253,7 +267,7 @@ export default function PurchaseOrderDetail() {
<DetailsTable fields={br} item={order} />
</ItemDetailsGrid>
);
}, [order, instanceQuery]);
}, [order, orderCurrency, instanceQuery]);

const orderPanels: PanelType[] = useMemo(() => {
return [
Expand Down
9 changes: 8 additions & 1 deletion src/frontend/src/pages/sales/ReturnOrderDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,15 @@ export default function ReturnOrderDetail() {
icon: 'user',
copy: true,
hidden: !order.contact
},
{
type: 'text',
name: 'project_code_label',
label: t`Project Code`,
icon: 'reference',
copy: true,
hidden: !order.project_code
}
// TODO: Project code
];

let br: DetailsField[] = [
Expand Down
9 changes: 8 additions & 1 deletion src/frontend/src/pages/sales/SalesOrderDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,15 @@ export default function SalesOrderDetail() {
icon: 'user',
copy: true,
hidden: !order.contact
},
{
type: 'text',
name: 'project_code_label',
label: t`Project Code`,
icon: 'reference',
copy: true,
hidden: !order.project_code
}
// TODO: Project code
];

let br: DetailsField[] = [
Expand Down
Loading

0 comments on commit c4031db

Please sign in to comment.