[PUI] PO line item import (#7671)

* Fix typo

* Adds new field to DataImportSession model

- field_filters
- Allows custom API field filters to be specified

* Update serializer

* Add button to import purchase order line items

* Fix instance renderer

* Make use of "filters" attribute

* Specify default currency for import

* Update serializer

* Bump API version

* Rename purchaseorderline -> purchaseorderlineitem
This commit is contained in:
Oliver 2024-07-17 17:44:42 +10:00 committed by GitHub
parent 453254c278
commit eacd28bf19
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 150 additions and 12 deletions

View File

@ -1,12 +1,15 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 224 INVENTREE_API_VERSION = 225
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v225 - 2024-07-17 : https://github.com/inventree/InvenTree/pull/7671
- Adds "filters" field to DataImportSession API
v224 - 2024-07-14 : https://github.com/inventree/InvenTree/pull/7667 v224 - 2024-07-14 : https://github.com/inventree/InvenTree/pull/7667
- Add notes field to ManufacturerPart and SupplierPart API endpoints - Add notes field to ManufacturerPart and SupplierPart API endpoints

View File

@ -0,0 +1,19 @@
# Generated by Django 4.2.14 on 2024-07-16 03:04
from django.db import migrations, models
import importer.validators
class Migration(migrations.Migration):
dependencies = [
('importer', '0002_dataimportsession_field_overrides'),
]
operations = [
migrations.AddField(
model_name='dataimportsession',
name='field_filters',
field=models.JSONField(blank=True, null=True, validators=[importer.validators.validate_field_defaults], verbose_name='Field Filters'),
),
]

View File

@ -32,8 +32,9 @@ class DataImportSession(models.Model):
data_file: FileField for the data file to import data_file: FileField for the data file to import
status: IntegerField for the status of the import session status: IntegerField for the status of the import session
user: ForeignKey to the User who initiated the import user: ForeignKey to the User who initiated the import
field_defaults: JSONField for field default values field_defaults: JSONField for field default values - provides a backup value for a field
field_overrides: JSONField for field override values field_overrides: JSONField for field override values - used to force a value for a field
field_filters: JSONField for field filter values - optional field API filters
""" """
@staticmethod @staticmethod
@ -101,6 +102,13 @@ class DataImportSession(models.Model):
validators=[importer.validators.validate_field_defaults], validators=[importer.validators.validate_field_defaults],
) )
field_filters = models.JSONField(
blank=True,
null=True,
verbose_name=_('Field Filters'),
validators=[importer.validators.validate_field_defaults],
)
@property @property
def field_mapping(self): def field_mapping(self):
"""Construct a dict of field mappings for this import session. """Construct a dict of field mappings for this import session.

View File

@ -50,6 +50,7 @@ class DataImportSessionSerializer(InvenTreeModelSerializer):
'column_mappings', 'column_mappings',
'field_defaults', 'field_defaults',
'field_overrides', 'field_overrides',
'field_filters',
'row_count', 'row_count',
'completed_row_count', 'completed_row_count',
] ]
@ -104,6 +105,19 @@ class DataImportSessionSerializer(InvenTreeModelSerializer):
return overrides return overrides
def validate_field_filters(self, filters):
"""De-stringify the field filters."""
if filters is None:
return None
if type(filters) is not dict:
try:
filters = json.loads(str(filters))
except:
raise ValidationError(_('Invalid field filters'))
return filters
def create(self, validated_data): def create(self, validated_data):
"""Override create method for this serializer. """Override create method for this serializer.

View File

@ -373,13 +373,13 @@ class PurchaseOrderLineItemSerializer(
fields = [ fields = [
'pk', 'pk',
'part',
'quantity', 'quantity',
'reference', 'reference',
'notes', 'notes',
'order', 'order',
'order_detail', 'order_detail',
'overdue', 'overdue',
'part',
'part_detail', 'part_detail',
'supplier_part_detail', 'supplier_part_detail',
'received', 'received',
@ -454,6 +454,14 @@ class PurchaseOrderLineItemSerializer(
return queryset return queryset
part = serializers.PrimaryKeyRelatedField(
queryset=part_models.SupplierPart.objects.all(),
many=False,
required=True,
allow_null=True,
label=_('Supplier Part'),
)
quantity = serializers.FloatField(min_value=0, required=True) quantity = serializers.FloatField(min_value=0, required=True)
def validate_quantity(self, quantity): def validate_quantity(self, quantity):

View File

@ -138,16 +138,27 @@ export default function ImporterDataSelector({
// Find the field definition in session.availableFields // Find the field definition in session.availableFields
let fieldDef = session.availableFields[field]; let fieldDef = session.availableFields[field];
if (fieldDef) { if (fieldDef) {
// Construct field filters based on session field filters
let filters = fieldDef.filters ?? {};
if (session.fieldFilters[field]) {
filters = {
...filters,
...session.fieldFilters[field]
};
}
fields[field] = { fields[field] = {
...fieldDef, ...fieldDef,
field_type: fieldDef.type, field_type: fieldDef.type,
description: fieldDef.help_text description: fieldDef.help_text,
filters: filters
}; };
} }
} }
return fields; return fields;
}, [selectedFieldNames, session.availableFields]); }, [selectedFieldNames, session.availableFields, session.fieldFilters]);
const importData = useCallback( const importData = useCallback(
(rows: number[]) => { (rows: number[]) => {

View File

@ -71,7 +71,7 @@ const RendererLookup: EnumDictionary<
[ModelType.parttesttemplate]: RenderPartTestTemplate, [ModelType.parttesttemplate]: RenderPartTestTemplate,
[ModelType.projectcode]: RenderProjectCode, [ModelType.projectcode]: RenderProjectCode,
[ModelType.purchaseorder]: RenderPurchaseOrder, [ModelType.purchaseorder]: RenderPurchaseOrder,
[ModelType.purchaseorderline]: RenderPurchaseOrder, [ModelType.purchaseorderlineitem]: RenderPurchaseOrder,
[ModelType.returnorder]: RenderReturnOrder, [ModelType.returnorder]: RenderReturnOrder,
[ModelType.salesorder]: RenderSalesOrder, [ModelType.salesorder]: RenderSalesOrder,
[ModelType.salesordershipment]: RenderSalesOrderShipment, [ModelType.salesordershipment]: RenderSalesOrderShipment,

View File

@ -143,7 +143,7 @@ export const ModelInformationDict: ModelDict = {
api_endpoint: ApiEndpoints.purchase_order_list, api_endpoint: ApiEndpoints.purchase_order_list,
admin_url: '/order/purchaseorder/' admin_url: '/order/purchaseorder/'
}, },
purchaseorderline: { purchaseorderlineitem: {
label: t`Purchase Order Line`, label: t`Purchase Order Line`,
label_multiple: t`Purchase Order Lines`, label_multiple: t`Purchase Order Lines`,
api_endpoint: ApiEndpoints.purchase_order_line_list api_endpoint: ApiEndpoints.purchase_order_line_list

View File

@ -9,7 +9,7 @@ import { ModelType } from '../enums/ModelType';
export const statusCodeList: Record<string, ModelType> = { export const statusCodeList: Record<string, ModelType> = {
BuildStatus: ModelType.build, BuildStatus: ModelType.build,
PurchaseOrderStatus: ModelType.purchaseorder, PurchaseOrderStatus: ModelType.purchaseorder,
ReturnOrderLineStatus: ModelType.purchaseorderline, ReturnOrderLineStatus: ModelType.purchaseorderlineitem,
ReturnOrderStatus: ModelType.returnorder, ReturnOrderStatus: ModelType.returnorder,
SalesOrderStatus: ModelType.salesorder, SalesOrderStatus: ModelType.salesorder,
StockHistoryCode: ModelType.stockhistory, StockHistoryCode: ModelType.stockhistory,

View File

@ -18,7 +18,7 @@ export enum ModelType {
builditem = 'builditem', builditem = 'builditem',
company = 'company', company = 'company',
purchaseorder = 'purchaseorder', purchaseorder = 'purchaseorder',
purchaseorderline = 'purchaseorderline', purchaseorderlineitem = 'purchaseorderlineitem',
salesorder = 'salesorder', salesorder = 'salesorder',
salesordershipment = 'salesordershipment', salesordershipment = 'salesordershipment',
returnorder = 'returnorder', returnorder = 'returnorder',

View File

@ -11,6 +11,10 @@ export function dataImporterSessionFields(): ApiFormFieldSet {
field_overrides: { field_overrides: {
hidden: true, hidden: true,
value: {} value: {}
},
field_filters: {
hidden: true,
value: {}
} }
}; };
} }

View File

@ -31,6 +31,7 @@ export type ImportSessionState = {
columnMappings: any[]; columnMappings: any[];
fieldDefaults: any; fieldDefaults: any;
fieldOverrides: any; fieldOverrides: any;
fieldFilters: any;
rowCount: number; rowCount: number;
completedRowCount: number; completedRowCount: number;
}; };
@ -113,6 +114,10 @@ export function useImportSession({
return sessionData?.field_overrides ?? {}; return sessionData?.field_overrides ?? {};
}, [sessionData]); }, [sessionData]);
const fieldFilters: any = useMemo(() => {
return sessionData?.field_filters ?? {};
}, [sessionData]);
const rowCount: number = useMemo(() => { const rowCount: number = useMemo(() => {
return sessionData?.row_count ?? 0; return sessionData?.row_count ?? 0;
}, [sessionData]); }, [sessionData]);
@ -134,6 +139,7 @@ export function useImportSession({
mappedFields, mappedFields,
fieldDefaults, fieldDefaults,
fieldOverrides, fieldOverrides,
fieldFilters,
rowCount, rowCount,
completedRowCount completedRowCount
}; };

View File

@ -242,6 +242,7 @@ export default function PurchaseOrderDetail() {
icon: <IconList />, icon: <IconList />,
content: ( content: (
<PurchaseOrderLineItemTable <PurchaseOrderLineItemTable
order={order}
orderId={Number(id)} orderId={Number(id)}
supplierId={Number(order.supplier)} supplierId={Number(order.supplier)}
/> />

View File

@ -561,7 +561,7 @@ export function BomTable({
</Stack> </Stack>
<ImporterDrawer <ImporterDrawer
sessionId={selectedSession ?? -1} sessionId={selectedSession ?? -1}
opened={selectedSession !== undefined && importOpened} opened={selectedSession != undefined && importOpened}
onClose={() => { onClose={() => {
setSelectedSession(undefined); setSelectedSession(undefined);
setImportOpened(false); setImportOpened(false);

View File

@ -1,16 +1,19 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Text } from '@mantine/core'; import { Text } from '@mantine/core';
import { IconSquareArrowRight } from '@tabler/icons-react'; import { Action } from '@mdxeditor/editor';
import { IconFileArrowLeft, IconSquareArrowRight } from '@tabler/icons-react';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { ActionButton } from '../../components/buttons/ActionButton'; import { ActionButton } from '../../components/buttons/ActionButton';
import { AddItemButton } from '../../components/buttons/AddItemButton'; import { AddItemButton } from '../../components/buttons/AddItemButton';
import { Thumbnail } from '../../components/images/Thumbnail'; import { Thumbnail } from '../../components/images/Thumbnail';
import ImporterDrawer from '../../components/importer/ImporterDrawer';
import { ProgressBar } from '../../components/items/ProgressBar'; import { ProgressBar } from '../../components/items/ProgressBar';
import { RenderStockLocation } from '../../components/render/Stock'; import { RenderStockLocation } from '../../components/render/Stock';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType'; import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles'; import { UserRoles } from '../../enums/Roles';
import { dataImporterSessionFields } from '../../forms/ImporterForms';
import { import {
usePurchaseOrderLineItemFields, usePurchaseOrderLineItemFields,
useReceiveLineItems useReceiveLineItems
@ -44,10 +47,12 @@ import { TableHoverCard } from '../TableHoverCard';
* Display a table of purchase order line items, for a specific order * Display a table of purchase order line items, for a specific order
*/ */
export function PurchaseOrderLineItemTable({ export function PurchaseOrderLineItemTable({
order,
orderId, orderId,
supplierId, supplierId,
params params
}: { }: {
order: any;
orderId: number; orderId: number;
supplierId?: number; supplierId?: number;
params?: any; params?: any;
@ -56,6 +61,49 @@ export function PurchaseOrderLineItemTable({
const user = useUserState(); const user = useUserState();
// Data import
const [importOpened, setImportOpened] = useState<boolean>(false);
const [selectedSession, setSelectedSession] = useState<number | undefined>(
undefined
);
const importSessionFields = useMemo(() => {
let fields = dataImporterSessionFields();
fields.model_type.hidden = true;
fields.model_type.value = ModelType.purchaseorderlineitem;
// Specify override values for import
fields.field_overrides.value = {
order: orderId
};
// Specify default values based on the order data
fields.field_defaults.value = {
purchase_price_currency:
order?.order_currency || order?.supplier_detail?.currency || undefined
};
fields.field_filters.value = {
part: {
supplier: supplierId,
active: true
}
};
return fields;
}, [order, orderId, supplierId]);
const importLineItems = useCreateApiFormModal({
url: ApiEndpoints.import_session_list,
title: t`Import Line Items`,
fields: importSessionFields,
onFormSuccess: (response: any) => {
setSelectedSession(response.pk);
setImportOpened(true);
}
});
const [singleRecord, setSingleRecord] = useState(null); const [singleRecord, setSingleRecord] = useState(null);
const receiveLineItems = useReceiveLineItems({ const receiveLineItems = useReceiveLineItems({
@ -277,6 +325,12 @@ export function PurchaseOrderLineItemTable({
// Custom table actions // Custom table actions
const tableActions = useMemo(() => { const tableActions = useMemo(() => {
return [ return [
<ActionButton
hidden={!user.hasAddRole(UserRoles.purchase_order)}
tooltip={t`Import Line Items`}
icon={<IconFileArrowLeft />}
onClick={() => importLineItems.open()}
/>,
<AddItemButton <AddItemButton
tooltip={t`Add line item`} tooltip={t`Add line item`}
onClick={() => { onClick={() => {
@ -298,6 +352,7 @@ export function PurchaseOrderLineItemTable({
return ( return (
<> <>
{importLineItems.modal}
{receiveLineItems.modal} {receiveLineItems.modal}
{newLine.modal} {newLine.modal}
{editLine.modal} {editLine.modal}
@ -320,6 +375,15 @@ export function PurchaseOrderLineItemTable({
modelField: 'part' modelField: 'part'
}} }}
/> />
<ImporterDrawer
sessionId={selectedSession ?? -1}
opened={selectedSession != undefined && importOpened}
onClose={() => {
setSelectedSession(undefined);
setImportOpened(false);
table.refreshTable();
}}
/>
</> </>
); );
} }