mirror of
https://github.com/frappe/books.git
synced 2024-11-08 06:44:06 +00:00
commit
e5a854c9d5
@ -138,7 +138,8 @@ export class BespokeQueries {
|
||||
location?: string,
|
||||
fromDate?: string,
|
||||
toDate?: string,
|
||||
batch?: string
|
||||
batch?: string,
|
||||
serialNo?: string
|
||||
): Promise<number | null> {
|
||||
const query = db.knex!(ModelNameEnum.StockLedgerEntry)
|
||||
.sum('quantity')
|
||||
@ -152,6 +153,10 @@ export class BespokeQueries {
|
||||
query.andWhere('batch', batch);
|
||||
}
|
||||
|
||||
if (serialNo) {
|
||||
query.andWhere('serialNo', serialNo);
|
||||
}
|
||||
|
||||
if (fromDate) {
|
||||
query.andWhereRaw('datetime(date) > datetime(?)', [fromDate]);
|
||||
}
|
||||
|
@ -313,7 +313,8 @@ export class DatabaseHandler extends DatabaseBase {
|
||||
location?: string,
|
||||
fromDate?: string,
|
||||
toDate?: string,
|
||||
batch?: string
|
||||
batch?: string,
|
||||
serialNo?: string
|
||||
): Promise<number | null> {
|
||||
return (await this.#demux.callBespoke(
|
||||
'getStockQuantity',
|
||||
@ -321,7 +322,8 @@ export class DatabaseHandler extends DatabaseBase {
|
||||
location,
|
||||
fromDate,
|
||||
toDate,
|
||||
batch
|
||||
batch,
|
||||
serialNo
|
||||
)) as number | null;
|
||||
}
|
||||
|
||||
|
@ -19,6 +19,7 @@ export class Item extends Doc {
|
||||
itemType?: 'Product' | 'Service';
|
||||
for?: 'Purchases' | 'Sales' | 'Both';
|
||||
hasBatch?: boolean;
|
||||
hasSerialNo?: boolean;
|
||||
|
||||
formulas: FormulaMap = {
|
||||
incomeAccount: {
|
||||
@ -124,6 +125,8 @@ export class Item extends Doc {
|
||||
barcode: () => !this.fyo.singles.InventorySettings?.enableBarcodes,
|
||||
hasBatch: () =>
|
||||
!(this.fyo.singles.InventorySettings?.enableBatches && this.trackItem),
|
||||
hasSerialNo: () =>
|
||||
!(this.fyo.singles.InventorySettings?.enableSerialNo && this.trackItem),
|
||||
uomConversions: () =>
|
||||
!this.fyo.singles.InventorySettings?.enableUomConversions,
|
||||
};
|
||||
@ -133,5 +136,6 @@ export class Item extends Doc {
|
||||
itemType: () => this.inserted,
|
||||
trackItem: () => this.inserted,
|
||||
hasBatch: () => this.inserted,
|
||||
hasSerialNo: () => this.inserted,
|
||||
};
|
||||
}
|
||||
|
@ -16,7 +16,7 @@ import {
|
||||
import { Invoice } from './baseModels/Invoice/Invoice';
|
||||
import { StockMovement } from './inventory/StockMovement';
|
||||
import { StockTransfer } from './inventory/StockTransfer';
|
||||
import { InvoiceStatus, ModelNameEnum } from './types';
|
||||
import { InvoiceStatus, SerialNoStatus, ModelNameEnum } from './types';
|
||||
|
||||
export function getInvoiceActions(
|
||||
fyo: Fyo,
|
||||
@ -248,6 +248,45 @@ export function getInvoiceStatus(doc: RenderData | Doc): InvoiceStatus {
|
||||
return 'Saved';
|
||||
}
|
||||
|
||||
export function getSerialNoStatusColumn(): ColumnConfig {
|
||||
return {
|
||||
label: t`Status`,
|
||||
fieldname: 'status',
|
||||
fieldtype: 'Select',
|
||||
render(doc) {
|
||||
const status = doc.status as SerialNoStatus;
|
||||
const color = serialNoStatusColor[status];
|
||||
const label = getSerialNoStatusText(status as string);
|
||||
|
||||
return {
|
||||
template: `<Badge class="text-xs" color="${color}">${label}</Badge>`,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const serialNoStatusColor: Record<SerialNoStatus, string | undefined> = {
|
||||
Inactive: 'gray',
|
||||
Active: 'green',
|
||||
Delivered: 'green',
|
||||
Expired: 'red',
|
||||
};
|
||||
|
||||
export function getSerialNoStatusText(status: string): string {
|
||||
switch (status) {
|
||||
case 'Inactive':
|
||||
return t`Inactive`;
|
||||
case 'Active':
|
||||
return t`Active`;
|
||||
case 'Delivered':
|
||||
return t`Delivered`;
|
||||
case 'Expired':
|
||||
return t`Expired`;
|
||||
default:
|
||||
return t`Inactive`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExchangeRate({
|
||||
fromCurrency,
|
||||
toCurrency,
|
||||
|
@ -19,6 +19,7 @@ import { SetupWizard } from './baseModels/SetupWizard/SetupWizard';
|
||||
import { Tax } from './baseModels/Tax/Tax';
|
||||
import { TaxSummary } from './baseModels/TaxSummary/TaxSummary';
|
||||
import { Batch } from './inventory/Batch';
|
||||
import { SerialNo } from './inventory/SerialNo';
|
||||
import { InventorySettings } from './inventory/InventorySettings';
|
||||
import { Location } from './inventory/Location';
|
||||
import { PurchaseReceipt } from './inventory/PurchaseReceipt';
|
||||
@ -48,6 +49,7 @@ export const models = {
|
||||
PurchaseInvoiceItem,
|
||||
SalesInvoice,
|
||||
SalesInvoiceItem,
|
||||
SerialNo,
|
||||
SetupWizard,
|
||||
PrintTemplate,
|
||||
Tax,
|
||||
|
@ -11,6 +11,7 @@ export class InventorySettings extends Doc {
|
||||
costOfGoodsSold?: string;
|
||||
enableBarcodes?: boolean;
|
||||
enableBatches?: boolean;
|
||||
enableSerialNo?: boolean;
|
||||
enableUomConversions?: boolean;
|
||||
|
||||
static filters: FiltersMap = {
|
||||
@ -35,6 +36,9 @@ export class InventorySettings extends Doc {
|
||||
enableBatches: () => {
|
||||
return !!this.enableBatches;
|
||||
},
|
||||
enableSerialNo: () => {
|
||||
return !!this.enableSerialNo;
|
||||
},
|
||||
enableUomConversions: () => {
|
||||
return !!this.enableUomConversions;
|
||||
},
|
||||
|
@ -1,11 +1,17 @@
|
||||
import { ListViewSettings } from 'fyo/model/types';
|
||||
import { getTransactionStatusColumn } from 'models/helpers';
|
||||
import { updateSerialNoStatus } from './helpers';
|
||||
import { PurchaseReceiptItem } from './PurchaseReceiptItem';
|
||||
import { StockTransfer } from './StockTransfer';
|
||||
|
||||
export class PurchaseReceipt extends StockTransfer {
|
||||
items?: PurchaseReceiptItem[];
|
||||
|
||||
async afterSubmit(): Promise<void> {
|
||||
await super.afterSubmit();
|
||||
await updateSerialNoStatus(this, this.items!, 'Active');
|
||||
}
|
||||
|
||||
static getListViewSettings(): ListViewSettings {
|
||||
return {
|
||||
columns: [
|
||||
|
17
models/inventory/SerialNo.ts
Normal file
17
models/inventory/SerialNo.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Doc } from 'fyo/model/doc';
|
||||
import { ListViewSettings } from 'fyo/model/types';
|
||||
import { getSerialNoStatusColumn } from 'models/helpers';
|
||||
|
||||
export class SerialNo extends Doc {
|
||||
static getListViewSettings(): ListViewSettings {
|
||||
return {
|
||||
columns: [
|
||||
'name',
|
||||
getSerialNoStatusColumn(),
|
||||
'item',
|
||||
'description',
|
||||
'party',
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
@ -1,11 +1,22 @@
|
||||
import { ListViewSettings } from 'fyo/model/types';
|
||||
import { getTransactionStatusColumn } from 'models/helpers';
|
||||
import { updateSerialNoStatus } from './helpers';
|
||||
import { ShipmentItem } from './ShipmentItem';
|
||||
import { StockTransfer } from './StockTransfer';
|
||||
|
||||
export class Shipment extends StockTransfer {
|
||||
items?: ShipmentItem[];
|
||||
|
||||
async afterSubmit(): Promise<void> {
|
||||
await super.afterSubmit();
|
||||
await updateSerialNoStatus(this, this.items!, 'Delivered');
|
||||
}
|
||||
|
||||
async afterCancel(): Promise<void> {
|
||||
await super.afterCancel();
|
||||
await updateSerialNoStatus(this, this.items!, 'Active');
|
||||
}
|
||||
|
||||
static getListViewSettings(): ListViewSettings {
|
||||
return {
|
||||
columns: [
|
||||
|
@ -11,6 +11,7 @@ export class StockLedgerEntry extends Doc {
|
||||
referenceName?: string;
|
||||
referenceType?: string;
|
||||
batch?: string;
|
||||
serialNo?: string;
|
||||
|
||||
static override getListViewSettings(): ListViewSettings {
|
||||
return {
|
||||
|
@ -4,6 +4,7 @@ import { ModelNameEnum } from 'models/types';
|
||||
import { Money } from 'pesa';
|
||||
import { StockLedgerEntry } from './StockLedgerEntry';
|
||||
import { SMDetails, SMIDetails, SMTransferDetails } from './types';
|
||||
import { getSerialNumbers } from './helpers';
|
||||
|
||||
export class StockManager {
|
||||
/**
|
||||
@ -133,15 +134,33 @@ export class StockManager {
|
||||
const date = details.date.toISOString();
|
||||
const formattedDate = this.fyo.format(details.date, 'Datetime');
|
||||
const batch = details.batch || undefined;
|
||||
const serialNo = details.serialNo || undefined;
|
||||
let quantityBefore = 0;
|
||||
|
||||
let quantityBefore =
|
||||
(await this.fyo.db.getStockQuantity(
|
||||
details.item,
|
||||
details.fromLocation,
|
||||
undefined,
|
||||
date,
|
||||
batch
|
||||
)) ?? 0;
|
||||
if (serialNo) {
|
||||
const serialNos = getSerialNumbers(serialNo);
|
||||
for (const serialNo of serialNos) {
|
||||
quantityBefore +=
|
||||
(await this.fyo.db.getStockQuantity(
|
||||
details.item,
|
||||
details.fromLocation,
|
||||
undefined,
|
||||
date,
|
||||
batch,
|
||||
serialNo
|
||||
)) ?? 0;
|
||||
}
|
||||
} else {
|
||||
quantityBefore =
|
||||
(await this.fyo.db.getStockQuantity(
|
||||
details.item,
|
||||
details.fromLocation,
|
||||
undefined,
|
||||
date,
|
||||
batch,
|
||||
serialNo
|
||||
)) ?? 0;
|
||||
}
|
||||
|
||||
if (this.isCancelled) {
|
||||
quantityBefore += details.quantity;
|
||||
@ -167,7 +186,8 @@ export class StockManager {
|
||||
details.fromLocation,
|
||||
details.date.toISOString(),
|
||||
undefined,
|
||||
batch
|
||||
batch,
|
||||
serialNo
|
||||
);
|
||||
|
||||
if (quantityAfter === null) {
|
||||
@ -211,6 +231,7 @@ class StockManagerItem {
|
||||
fromLocation?: string;
|
||||
toLocation?: string;
|
||||
batch?: string;
|
||||
serialNo?: string;
|
||||
|
||||
stockLedgerEntries?: StockLedgerEntry[];
|
||||
|
||||
@ -226,6 +247,7 @@ class StockManagerItem {
|
||||
this.referenceName = details.referenceName;
|
||||
this.referenceType = details.referenceType;
|
||||
this.batch = details.batch;
|
||||
this.serialNo = details.serialNo;
|
||||
|
||||
this.fyo = fyo;
|
||||
}
|
||||
@ -260,10 +282,30 @@ class StockManagerItem {
|
||||
|
||||
#moveStockForSingleLocation(location: string, isOutward: boolean) {
|
||||
let quantity = this.quantity!;
|
||||
const serialNo = this.serialNo;
|
||||
if (quantity === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (serialNo) {
|
||||
const serialNos = getSerialNumbers(serialNo!);
|
||||
if (isOutward) {
|
||||
quantity = -1;
|
||||
} else {
|
||||
quantity = 1;
|
||||
}
|
||||
|
||||
for (const serialNo of serialNos) {
|
||||
const stockLedgerEntry = this.#getStockLedgerEntry(
|
||||
location,
|
||||
quantity,
|
||||
serialNo!
|
||||
);
|
||||
this.stockLedgerEntries?.push(stockLedgerEntry);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOutward) {
|
||||
quantity = -quantity;
|
||||
}
|
||||
@ -273,12 +315,13 @@ class StockManagerItem {
|
||||
this.stockLedgerEntries?.push(stockLedgerEntry);
|
||||
}
|
||||
|
||||
#getStockLedgerEntry(location: string, quantity: number) {
|
||||
#getStockLedgerEntry(location: string, quantity: number, serialNo?: string) {
|
||||
return this.fyo.doc.getNewDoc(ModelNameEnum.StockLedgerEntry, {
|
||||
date: this.date,
|
||||
item: this.item,
|
||||
rate: this.rate,
|
||||
batch: this.batch || null,
|
||||
serialNo: serialNo || null,
|
||||
quantity,
|
||||
location,
|
||||
referenceName: this.referenceName,
|
||||
|
@ -15,7 +15,12 @@ import {
|
||||
import { LedgerPosting } from 'models/Transactional/LedgerPosting';
|
||||
import { ModelNameEnum } from 'models/types';
|
||||
import { Money } from 'pesa';
|
||||
import { validateBatch } from './helpers';
|
||||
import {
|
||||
getSerialNumbers,
|
||||
updateSerialNoStatus,
|
||||
validateBatch,
|
||||
validateSerialNo,
|
||||
} from './helpers';
|
||||
import { StockMovementItem } from './StockMovementItem';
|
||||
import { Transfer } from './Transfer';
|
||||
import { MovementType } from './types';
|
||||
@ -52,6 +57,7 @@ export class StockMovement extends Transfer {
|
||||
await super.validate();
|
||||
this.validateManufacture();
|
||||
await validateBatch(this);
|
||||
await validateSerialNo(this);
|
||||
}
|
||||
|
||||
validateManufacture() {
|
||||
@ -71,6 +77,16 @@ export class StockMovement extends Transfer {
|
||||
}
|
||||
}
|
||||
|
||||
async afterSubmit(): Promise<void> {
|
||||
await super.afterSubmit();
|
||||
await updateSerialNoStatus(this, this.items!, 'Active');
|
||||
}
|
||||
|
||||
async afterCancel(): Promise<void> {
|
||||
await super.afterCancel();
|
||||
await updateSerialNoStatus(this, this.items!, 'Inactive');
|
||||
}
|
||||
|
||||
static filters: FiltersMap = {
|
||||
numberSeries: () => ({ referenceType: ModelNameEnum.StockMovement }),
|
||||
};
|
||||
@ -110,6 +126,7 @@ export class StockMovement extends Transfer {
|
||||
rate: row.rate!,
|
||||
quantity: row.quantity!,
|
||||
batch: row.batch!,
|
||||
serialNo: row.serialNo!,
|
||||
fromLocation: row.fromLocation,
|
||||
toLocation: row.toLocation,
|
||||
}));
|
||||
|
@ -32,6 +32,7 @@ export class StockMovementItem extends Doc {
|
||||
amount?: Money;
|
||||
parentdoc?: StockMovement;
|
||||
batch?: string;
|
||||
serialNo?: string;
|
||||
|
||||
get isIssue() {
|
||||
return this.parentdoc?.movementType === MovementType.MaterialIssue;
|
||||
@ -236,6 +237,7 @@ export class StockMovementItem extends Doc {
|
||||
|
||||
override hidden: HiddenMap = {
|
||||
batch: () => !this.fyo.singles.InventorySettings?.enableBatches,
|
||||
serialNo: () => !this.fyo.singles.InventorySettings?.enableSerialNo,
|
||||
transferUnit: () =>
|
||||
!this.fyo.singles.InventorySettings?.enableUomConversions,
|
||||
transferQuantity: () =>
|
||||
|
@ -17,7 +17,7 @@ import { LedgerPosting } from 'models/Transactional/LedgerPosting';
|
||||
import { ModelNameEnum } from 'models/types';
|
||||
import { Money } from 'pesa';
|
||||
import { TargetField } from 'schemas/types';
|
||||
import { validateBatch } from './helpers';
|
||||
import { validateBatch, validateSerialNo } from './helpers';
|
||||
import { StockTransferItem } from './StockTransferItem';
|
||||
import { Transfer } from './Transfer';
|
||||
|
||||
@ -91,6 +91,7 @@ export abstract class StockTransfer extends Transfer {
|
||||
rate: row.rate!,
|
||||
quantity: row.quantity!,
|
||||
batch: row.batch!,
|
||||
serialNo: row.serialNo!,
|
||||
fromLocation,
|
||||
toLocation,
|
||||
};
|
||||
@ -162,6 +163,7 @@ export abstract class StockTransfer extends Transfer {
|
||||
override async validate(): Promise<void> {
|
||||
await super.validate();
|
||||
await validateBatch(this);
|
||||
await validateSerialNo(this);
|
||||
}
|
||||
|
||||
static getActions(fyo: Fyo): Action[] {
|
||||
|
@ -28,6 +28,7 @@ export class StockTransferItem extends Doc {
|
||||
description?: string;
|
||||
hsnCode?: number;
|
||||
batch?: string;
|
||||
serialNo?: string;
|
||||
|
||||
parentdoc?: StockTransfer;
|
||||
|
||||
@ -215,6 +216,7 @@ export class StockTransferItem extends Doc {
|
||||
|
||||
override hidden: HiddenMap = {
|
||||
batch: () => !this.fyo.singles.InventorySettings?.enableBatches,
|
||||
serialNo: () => !this.fyo.singles.InventorySettings?.enableSerialNo,
|
||||
transferUnit: () =>
|
||||
!this.fyo.singles.InventorySettings?.enableUomConversions,
|
||||
transferQuantity: () =>
|
||||
|
@ -1,7 +1,11 @@
|
||||
import { t } from 'fyo';
|
||||
import { Doc } from 'fyo/model/doc';
|
||||
import { DocValueMap } from 'fyo/core/types';
|
||||
import { ValidationError } from 'fyo/utils/errors';
|
||||
import { Invoice } from 'models/baseModels/Invoice/Invoice';
|
||||
import { InvoiceItem } from 'models/baseModels/InvoiceItem/InvoiceItem';
|
||||
import { ModelNameEnum } from 'models/types';
|
||||
import { ShipmentItem } from './ShipmentItem';
|
||||
import { StockMovement } from './StockMovement';
|
||||
import { StockMovementItem } from './StockMovementItem';
|
||||
import { StockTransfer } from './StockTransfer';
|
||||
@ -25,11 +29,7 @@ async function validateItemRowBatch(
|
||||
return;
|
||||
}
|
||||
|
||||
const hasBatch = await doc.fyo.getValue(
|
||||
ModelNameEnum.Item,
|
||||
item,
|
||||
'hasBatch'
|
||||
);
|
||||
const hasBatch = await doc.fyo.getValue(ModelNameEnum.Item, item, 'hasBatch');
|
||||
|
||||
if (!hasBatch && batch) {
|
||||
throw new ValidationError(
|
||||
@ -49,3 +49,211 @@ async function validateItemRowBatch(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateSerialNo(
|
||||
doc: StockMovement | StockTransfer | Invoice
|
||||
) {
|
||||
for (const row of doc.items ?? []) {
|
||||
await validateItemRowSerialNo(row, doc.movementType as string);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateItemRowSerialNo(
|
||||
doc: StockMovementItem | StockTransferItem | InvoiceItem,
|
||||
movementType: string
|
||||
) {
|
||||
const idx = doc.idx ?? 0 + 1;
|
||||
const item = doc.item;
|
||||
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (doc.parentdoc?.cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasSerialNo = await doc.fyo.getValue(
|
||||
ModelNameEnum.Item,
|
||||
item,
|
||||
'hasSerialNo'
|
||||
);
|
||||
|
||||
if (hasSerialNo && !doc.serialNo) {
|
||||
throw new ValidationError(
|
||||
[
|
||||
doc.fyo.t`Serial No not set for row ${idx}.`,
|
||||
doc.fyo.t`Serial No is enabled for Item ${item}`,
|
||||
].join(' ')
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasSerialNo && doc.serialNo) {
|
||||
throw new ValidationError(
|
||||
[
|
||||
doc.fyo.t`Serial No set for row ${idx}.`,
|
||||
doc.fyo.t`Serial No is not enabled for Item ${item}`,
|
||||
].join(' ')
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasSerialNo) return;
|
||||
|
||||
const serialNos = getSerialNumbers(doc.serialNo as string);
|
||||
|
||||
if (serialNos.length !== doc.quantity) {
|
||||
throw new ValidationError(
|
||||
t`${doc.quantity!} Serial Numbers required for ${doc.item!}. You have provided ${
|
||||
serialNos.length
|
||||
}.`
|
||||
);
|
||||
}
|
||||
|
||||
for (const serialNo of serialNos) {
|
||||
const { name, status, item } = await doc.fyo.db.get(
|
||||
ModelNameEnum.SerialNo,
|
||||
serialNo,
|
||||
['name', 'status', 'item']
|
||||
);
|
||||
|
||||
if (movementType == 'MaterialIssue') {
|
||||
await validateSNMaterialIssue(
|
||||
doc,
|
||||
name as string,
|
||||
item as string,
|
||||
serialNo,
|
||||
status as string
|
||||
);
|
||||
}
|
||||
|
||||
if (movementType == 'MaterialReceipt') {
|
||||
await validateSNMaterialReceipt(
|
||||
doc,
|
||||
name as string,
|
||||
serialNo,
|
||||
status as string
|
||||
);
|
||||
}
|
||||
|
||||
if (movementType === 'Shipment') {
|
||||
await validateSNShipment(doc, serialNo);
|
||||
}
|
||||
|
||||
if (doc.parentSchemaName === 'PurchaseReceipt') {
|
||||
await validateSNPurchaseReceipt(
|
||||
doc,
|
||||
name as string,
|
||||
serialNo,
|
||||
status as string
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const getSerialNumbers = (serialNo: string): string[] => {
|
||||
return serialNo ? serialNo.split('\n') : [];
|
||||
};
|
||||
|
||||
export const updateSerialNoStatus = async (
|
||||
doc: Doc,
|
||||
items: StockMovementItem[] | ShipmentItem[],
|
||||
newStatus: string
|
||||
) => {
|
||||
for (const item of items) {
|
||||
const serialNos = getSerialNumbers(item.serialNo!);
|
||||
if (!serialNos.length) break;
|
||||
|
||||
for (const serialNo of serialNos) {
|
||||
await doc.fyo.db.update(ModelNameEnum.SerialNo, {
|
||||
name: serialNo,
|
||||
status: newStatus,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const validateSNMaterialReceipt = async (
|
||||
doc: Doc,
|
||||
name: string,
|
||||
serialNo: string,
|
||||
status: string
|
||||
) => {
|
||||
if (name === undefined) {
|
||||
const values = {
|
||||
name: serialNo,
|
||||
item: doc.item,
|
||||
party: doc.parentdoc?.party as string,
|
||||
};
|
||||
(
|
||||
await doc.fyo.doc
|
||||
.getNewDoc(ModelNameEnum.SerialNo, values as DocValueMap)
|
||||
.sync()
|
||||
).submit();
|
||||
}
|
||||
|
||||
if (status && status !== 'Inactive') {
|
||||
throw new ValidationError(t`SerialNo ${serialNo} status is not Inactive`);
|
||||
}
|
||||
};
|
||||
|
||||
const validateSNPurchaseReceipt = async (
|
||||
doc: Doc,
|
||||
name: string,
|
||||
serialNo: string,
|
||||
status: string
|
||||
) => {
|
||||
if (name === undefined) {
|
||||
const values = {
|
||||
name: serialNo,
|
||||
item: doc.item,
|
||||
party: doc.parentdoc?.party as string,
|
||||
status: 'Inactive',
|
||||
};
|
||||
(
|
||||
await doc.fyo.doc
|
||||
.getNewDoc(ModelNameEnum.SerialNo, values as DocValueMap)
|
||||
.sync()
|
||||
).submit();
|
||||
}
|
||||
|
||||
if (status && status !== 'Inactive') {
|
||||
throw new ValidationError(t`SerialNo ${serialNo} status is not Inactive`);
|
||||
}
|
||||
};
|
||||
|
||||
const validateSNMaterialIssue = async (
|
||||
doc: Doc,
|
||||
name: string,
|
||||
item: string,
|
||||
serialNo: string,
|
||||
status: string
|
||||
) => {
|
||||
if (doc.isCancelled) return;
|
||||
|
||||
if (!name)
|
||||
throw new ValidationError(t`Serial Number ${serialNo} does not exist.`);
|
||||
|
||||
if (status !== 'Active')
|
||||
throw new ValidationError(
|
||||
t`Serial Number ${serialNo} status is not Active`
|
||||
);
|
||||
if (doc.item !== item) {
|
||||
throw new ValidationError(
|
||||
t`Serial Number ${serialNo} does not belong to the item ${
|
||||
doc.item! as string
|
||||
}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const validateSNShipment = async (doc: Doc, serialNo: string) => {
|
||||
const { status } = await doc.fyo.db.get(
|
||||
ModelNameEnum.SerialNo,
|
||||
serialNo,
|
||||
'status'
|
||||
);
|
||||
|
||||
if (status !== 'Active')
|
||||
throw new ValidationError(t`Serial No ${serialNo} status is not Active`);
|
||||
};
|
||||
|
@ -28,6 +28,7 @@ type Transfer = {
|
||||
from?: string;
|
||||
to?: string;
|
||||
batch?: string;
|
||||
serialNo?: string;
|
||||
quantity: number;
|
||||
rate: number;
|
||||
};
|
||||
@ -36,8 +37,8 @@ interface TransferTwo extends Omit<Transfer, 'from' | 'to'> {
|
||||
location: string;
|
||||
}
|
||||
|
||||
export function getItem(name: string, rate: number, hasBatch: boolean = false) {
|
||||
return { name, rate, trackItem: true, hasBatch };
|
||||
export function getItem(name: string, rate: number, hasBatch: boolean = false, hasSerialNo: boolean = false) {
|
||||
return { name, rate, trackItem: true, hasBatch, hasSerialNo };
|
||||
}
|
||||
|
||||
export async function getBatch(
|
||||
@ -84,6 +85,7 @@ export async function getStockMovement(
|
||||
from: fromLocation,
|
||||
to: toLocation,
|
||||
batch,
|
||||
serialNo,
|
||||
quantity,
|
||||
rate,
|
||||
} of transfers) {
|
||||
@ -92,6 +94,7 @@ export async function getStockMovement(
|
||||
fromLocation,
|
||||
toLocation,
|
||||
batch,
|
||||
serialNo,
|
||||
rate,
|
||||
quantity,
|
||||
});
|
||||
|
312
models/inventory/tests/testSerialNos.spec.ts
Normal file
312
models/inventory/tests/testSerialNos.spec.ts
Normal file
@ -0,0 +1,312 @@
|
||||
import { assertThrows } from 'backend/database/tests/helpers';
|
||||
import { ModelNameEnum } from 'models/types';
|
||||
import test from 'tape';
|
||||
import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers';
|
||||
import { MovementType } from '../types';
|
||||
import { getItem, getStockMovement } from './helpers';
|
||||
|
||||
const fyo = getTestFyo();
|
||||
|
||||
setupTestFyo(fyo, __filename);
|
||||
|
||||
const itemMap = {
|
||||
Pen: {
|
||||
name: 'Pen',
|
||||
rate: 700,
|
||||
},
|
||||
Ink: {
|
||||
name: 'Ink',
|
||||
rate: 50,
|
||||
},
|
||||
};
|
||||
|
||||
const locationMap = {
|
||||
LocationOne: 'LocationOne',
|
||||
LocationTwo: 'LocationTwo',
|
||||
};
|
||||
|
||||
const partyMap = {
|
||||
partyOne: { name: 'Someone', Role: 'Both' },
|
||||
};
|
||||
|
||||
const serialNoMap = {
|
||||
serialOne: {
|
||||
name: 'PN-AB001',
|
||||
item: itemMap.Pen.name,
|
||||
},
|
||||
serialTwo: {
|
||||
name: 'PN-AB002',
|
||||
item: itemMap.Pen.name,
|
||||
},
|
||||
serialThree: {
|
||||
name: 'PN-AB003',
|
||||
item: itemMap.Pen.name,
|
||||
},
|
||||
};
|
||||
|
||||
test('create dummy items, locations, party & serialNos', async (t) => {
|
||||
// Create Items
|
||||
for (const { name, rate } of Object.values(itemMap)) {
|
||||
const item = getItem(name, rate, false, true);
|
||||
await fyo.doc.getNewDoc(ModelNameEnum.Item, item).sync();
|
||||
}
|
||||
|
||||
// Create Locations
|
||||
for (const name of Object.values(locationMap)) {
|
||||
await fyo.doc.getNewDoc(ModelNameEnum.Location, { name }).sync();
|
||||
}
|
||||
|
||||
// Create Party
|
||||
await fyo.doc.getNewDoc(ModelNameEnum.Party, partyMap.partyOne).sync();
|
||||
|
||||
t.ok(
|
||||
await fyo.db.exists(ModelNameEnum.Party, partyMap.partyOne.name),
|
||||
'party created'
|
||||
);
|
||||
|
||||
// Create SerialNos
|
||||
for (const serialNo of Object.values(serialNoMap)) {
|
||||
const doc = fyo.doc.getNewDoc(ModelNameEnum.SerialNo, serialNo);
|
||||
await doc.sync();
|
||||
|
||||
const status = await fyo.getValue(
|
||||
ModelNameEnum.SerialNo,
|
||||
serialNo.name,
|
||||
'status'
|
||||
);
|
||||
t.equal(
|
||||
status,
|
||||
'Inactive',
|
||||
`${serialNo.name} exists and inital status Inactive`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('serialNo enabled item, create stock movement, material receipt', async (t) => {
|
||||
const { rate } = itemMap.Pen;
|
||||
const serialNo =
|
||||
serialNoMap.serialOne.name + '\n' + serialNoMap.serialTwo.name;
|
||||
const stockMovement = await getStockMovement(
|
||||
MovementType.MaterialReceipt,
|
||||
new Date('2022-11-03T09:57:04.528'),
|
||||
[
|
||||
{
|
||||
item: itemMap.Pen.name,
|
||||
to: locationMap.LocationOne,
|
||||
quantity: 2,
|
||||
serialNo,
|
||||
rate,
|
||||
},
|
||||
],
|
||||
fyo
|
||||
);
|
||||
|
||||
await (await stockMovement.sync()).submit();
|
||||
|
||||
t.equal(
|
||||
await fyo.db.getStockQuantity(
|
||||
itemMap.Pen.name,
|
||||
locationMap.LocationOne,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
serialNoMap.serialOne.name
|
||||
),
|
||||
1,
|
||||
'serialNo one has quantity one'
|
||||
);
|
||||
|
||||
t.equal(
|
||||
await fyo.db.getStockQuantity(
|
||||
itemMap.Pen.name,
|
||||
locationMap.LocationOne,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
serialNoMap.serialTwo.name
|
||||
),
|
||||
1,
|
||||
'serialNo two has quantity one'
|
||||
);
|
||||
|
||||
t.equal(
|
||||
await fyo.db.getStockQuantity(
|
||||
itemMap.Pen.name,
|
||||
locationMap.LocationOne,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
serialNoMap.serialThree.name
|
||||
),
|
||||
null,
|
||||
'serialNo three has no quantity'
|
||||
);
|
||||
|
||||
t.equal(
|
||||
await fyo.db.getStockQuantity(
|
||||
itemMap.Ink.name,
|
||||
locationMap.LocationOne,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
serialNoMap.serialOne.name
|
||||
),
|
||||
null,
|
||||
'non transacted item has no quantity'
|
||||
);
|
||||
});
|
||||
|
||||
test('serialNo enabled item, create stock movement, material issue', async (t) => {
|
||||
const { rate } = itemMap.Pen;
|
||||
const quantity = 1;
|
||||
|
||||
const stockMovement = await getStockMovement(
|
||||
MovementType.MaterialIssue,
|
||||
new Date('2022-11-03T10:00:00.528'),
|
||||
[
|
||||
{
|
||||
item: itemMap.Pen.name,
|
||||
from: locationMap.LocationOne,
|
||||
serialNo: serialNoMap.serialOne.name,
|
||||
quantity,
|
||||
rate,
|
||||
},
|
||||
],
|
||||
fyo
|
||||
);
|
||||
|
||||
await (await stockMovement.sync()).submit();
|
||||
t.equal(
|
||||
await fyo.db.getStockQuantity(
|
||||
itemMap.Pen.name,
|
||||
locationMap.LocationOne,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
serialNoMap.serialOne.name
|
||||
),
|
||||
0,
|
||||
'serialNo one quantity transacted out'
|
||||
);
|
||||
|
||||
t.equal(
|
||||
await fyo.db.getStockQuantity(
|
||||
itemMap.Pen.name,
|
||||
locationMap.LocationOne,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
serialNoMap.serialTwo.name
|
||||
),
|
||||
1,
|
||||
'serialNo two quantity intact'
|
||||
);
|
||||
});
|
||||
|
||||
test('serialNo enabled item, create stock movement, material transfer', async (t) => {
|
||||
const { rate } = itemMap.Pen;
|
||||
const quantity = 1;
|
||||
const serialNo = serialNoMap.serialTwo.name;
|
||||
|
||||
const stockMovement = await getStockMovement(
|
||||
MovementType.MaterialTransfer,
|
||||
new Date('2022-11-03T09:58:04.528'),
|
||||
[
|
||||
{
|
||||
item: itemMap.Pen.name,
|
||||
from: locationMap.LocationOne,
|
||||
to: locationMap.LocationTwo,
|
||||
serialNo,
|
||||
quantity,
|
||||
rate,
|
||||
},
|
||||
],
|
||||
fyo
|
||||
);
|
||||
|
||||
await (await stockMovement.sync()).submit();
|
||||
t.equal(
|
||||
await fyo.db.getStockQuantity(
|
||||
itemMap.Pen.name,
|
||||
locationMap.LocationOne,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
serialNo
|
||||
),
|
||||
0,
|
||||
'location one serialNoTwo transacted out'
|
||||
);
|
||||
|
||||
t.equal(
|
||||
await fyo.db.getStockQuantity(
|
||||
itemMap.Pen.name,
|
||||
locationMap.LocationTwo,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
serialNo
|
||||
),
|
||||
quantity,
|
||||
'location two serialNo transacted in'
|
||||
);
|
||||
});
|
||||
|
||||
test('serialNo enabled item, create invalid stock movements', async (t) => {
|
||||
const { name, rate } = itemMap.Pen;
|
||||
const quantity = await fyo.db.getStockQuantity(
|
||||
itemMap.Pen.name,
|
||||
locationMap.LocationTwo,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
serialNoMap.serialTwo.name
|
||||
);
|
||||
|
||||
t.equal(quantity, 1, 'location two, serialNo one has quantity');
|
||||
if (!quantity) {
|
||||
return;
|
||||
}
|
||||
|
||||
let stockMovement = await getStockMovement(
|
||||
MovementType.MaterialIssue,
|
||||
new Date('2022-11-03T09:59:04.528'),
|
||||
[
|
||||
{
|
||||
item: itemMap.Pen.name,
|
||||
from: locationMap.LocationTwo,
|
||||
serialNo: serialNoMap.serialOne.name,
|
||||
quantity,
|
||||
rate,
|
||||
},
|
||||
],
|
||||
fyo
|
||||
);
|
||||
|
||||
await assertThrows(
|
||||
async () => (await stockMovement.sync()).submit(),
|
||||
'invalid stockMovement with insufficient quantity did not throw'
|
||||
);
|
||||
|
||||
stockMovement = await getStockMovement(
|
||||
MovementType.MaterialIssue,
|
||||
new Date('2022-11-03T09:59:04.528'),
|
||||
[
|
||||
{
|
||||
item: itemMap.Pen.name,
|
||||
from: locationMap.LocationTwo,
|
||||
quantity,
|
||||
rate,
|
||||
},
|
||||
],
|
||||
fyo
|
||||
);
|
||||
|
||||
await assertThrows(
|
||||
async () => (await stockMovement.sync()).submit(),
|
||||
'invalid stockMovement without serialNo did not throw'
|
||||
);
|
||||
t.equal(await fyo.db.getStockQuantity(name), 1, 'item still has quantity');
|
||||
});
|
||||
|
||||
closeTestFyo(fyo, __filename);
|
@ -23,6 +23,7 @@ export interface SMTransferDetails {
|
||||
rate: Money;
|
||||
quantity: number;
|
||||
batch?: string;
|
||||
serialNo?: string;
|
||||
fromLocation?: string;
|
||||
toLocation?: string;
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
export type InvoiceStatus = 'Draft' | 'Saved' | 'Unpaid' | 'Cancelled' | 'Paid';
|
||||
export type SerialNoStatus = 'Inactive' | 'Active' | 'Delivered' | 'Expired';
|
||||
export enum ModelNameEnum {
|
||||
Account = 'Account',
|
||||
AccountingLedgerEntry = 'AccountingLedgerEntry',
|
||||
@ -25,6 +26,7 @@ export enum ModelNameEnum {
|
||||
PurchaseInvoiceItem = 'PurchaseInvoiceItem',
|
||||
SalesInvoice = 'SalesInvoice',
|
||||
SalesInvoiceItem = 'SalesInvoiceItem',
|
||||
SerialNo = 'SerialNo',
|
||||
SetupWizard = 'SetupWizard',
|
||||
Tax = 'Tax',
|
||||
TaxDetail = 'TaxDetail',
|
||||
|
@ -28,6 +28,7 @@ export class StockLedger extends Report {
|
||||
item?: string;
|
||||
location?: string;
|
||||
batch?: string;
|
||||
serialNo?: string;
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
ascending?: boolean;
|
||||
@ -41,6 +42,11 @@ export class StockLedger extends Report {
|
||||
.enableBatches;
|
||||
}
|
||||
|
||||
get hasSerialNos(): boolean {
|
||||
return !!(this.fyo.singles.InventorySettings as InventorySettings)
|
||||
.enableSerialNo;
|
||||
}
|
||||
|
||||
constructor(fyo: Fyo) {
|
||||
super(fyo);
|
||||
this._setObservers();
|
||||
@ -276,6 +282,11 @@ export class StockLedger extends Report {
|
||||
{ fieldname: 'batch', label: 'Batch', fieldtype: 'Link' },
|
||||
] as ColumnField[])
|
||||
: []),
|
||||
...(this.hasSerialNos
|
||||
? ([
|
||||
{ fieldname: 'serialNo', label: 'Serial No', fieldtype: 'Data' },
|
||||
] as ColumnField[])
|
||||
: []),
|
||||
{
|
||||
fieldname: 'quantity',
|
||||
label: 'Quantity',
|
||||
|
@ -19,6 +19,7 @@ export async function getRawStockLedgerEntries(fyo: Fyo) {
|
||||
'date',
|
||||
'item',
|
||||
'batch',
|
||||
'serialNo',
|
||||
'rate',
|
||||
'quantity',
|
||||
'location',
|
||||
@ -49,6 +50,7 @@ export function getStockLedgerEntries(
|
||||
const rate = safeParseFloat(sle.rate);
|
||||
const { item, location, quantity, referenceName, referenceType } = sle;
|
||||
const batch = sle.batch ?? '';
|
||||
const serialNo = sle.serialNo ?? '';
|
||||
|
||||
if (quantity === 0) {
|
||||
continue;
|
||||
@ -88,6 +90,7 @@ export function getStockLedgerEntries(
|
||||
item,
|
||||
location,
|
||||
batch,
|
||||
serialNo,
|
||||
|
||||
quantity,
|
||||
balanceQuantity,
|
||||
|
@ -6,6 +6,7 @@ export interface RawStockLedgerEntry {
|
||||
item: string;
|
||||
rate: string;
|
||||
batch: string | null;
|
||||
serialNo: string | null;
|
||||
quantity: number;
|
||||
location: string;
|
||||
referenceName: string;
|
||||
@ -21,6 +22,7 @@ export interface ComputedStockLedgerEntry{
|
||||
item: string;
|
||||
location:string;
|
||||
batch: string;
|
||||
serialNo: string;
|
||||
|
||||
quantity: number;
|
||||
balanceQuantity: number;
|
||||
|
@ -138,6 +138,13 @@
|
||||
"default": false,
|
||||
"section": "Inventory"
|
||||
},
|
||||
{
|
||||
"fieldname": "hasSerialNo",
|
||||
"label": "Has Serial No",
|
||||
"fieldtype": "Check",
|
||||
"default": false,
|
||||
"section": "Inventory"
|
||||
},
|
||||
{
|
||||
"fieldname": "uomConversions",
|
||||
"label": "UOM Conversions",
|
||||
|
@ -63,6 +63,12 @@
|
||||
"fieldtype": "Check",
|
||||
"section": "Features"
|
||||
},
|
||||
{
|
||||
"fieldname": "enableSerialNo",
|
||||
"label": "Enable Serial No",
|
||||
"fieldtype": "Check",
|
||||
"section": "Features"
|
||||
},
|
||||
{
|
||||
"fieldname": "enableUomConversions",
|
||||
"label": "Enable UOM Conversion",
|
||||
|
36
schemas/app/inventory/SerialNo.json
Normal file
36
schemas/app/inventory/SerialNo.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "SerialNo",
|
||||
"label": "Serial No",
|
||||
"naming": "manual",
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Serial Number",
|
||||
"create": true,
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"fieldname": "item",
|
||||
"fieldtype": "Link",
|
||||
"label": "Item",
|
||||
"target": "Item",
|
||||
"create": true,
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"fieldname": "description",
|
||||
"label": "Description",
|
||||
"placeholder": "Serial Number Description",
|
||||
"fieldtype": "Text"
|
||||
},
|
||||
{
|
||||
"fieldname": "status",
|
||||
"label": "Status",
|
||||
"fieldtype": "Data",
|
||||
"default": "Inactive"
|
||||
}
|
||||
],
|
||||
"quickEditFields": ["item", "description", "party"],
|
||||
"keywordFields": ["name"]
|
||||
}
|
@ -37,6 +37,14 @@
|
||||
"readOnly": true,
|
||||
"section": "Details"
|
||||
},
|
||||
{
|
||||
"fieldname": "serialNo",
|
||||
"label": "Serial No",
|
||||
"fieldtype": "Link",
|
||||
"target": "SerialNo",
|
||||
"readOnly": true,
|
||||
"section": "Details"
|
||||
},
|
||||
{
|
||||
"fieldname": "item",
|
||||
"label": "Item",
|
||||
|
@ -57,6 +57,11 @@
|
||||
"target": "Batch",
|
||||
"create": true
|
||||
},
|
||||
{
|
||||
"fieldname": "serialNo",
|
||||
"label": "Serial No",
|
||||
"fieldtype": "Text"
|
||||
},
|
||||
{
|
||||
"fieldname": "quantity",
|
||||
"label": "Quantity",
|
||||
@ -94,6 +99,7 @@
|
||||
"transferQuantity",
|
||||
"transferUnit",
|
||||
"batch",
|
||||
"serialNo",
|
||||
"quantity",
|
||||
"unit",
|
||||
"unitConversionFactor",
|
||||
|
@ -47,6 +47,11 @@
|
||||
"fieldtype": "Link",
|
||||
"target": "Batch"
|
||||
},
|
||||
{
|
||||
"fieldname": "serialNo",
|
||||
"label": "Serial No",
|
||||
"fieldtype": "Text"
|
||||
},
|
||||
{
|
||||
"fieldname": "quantity",
|
||||
"label": "Quantity",
|
||||
@ -93,6 +98,7 @@
|
||||
"transferQuantity",
|
||||
"transferUnit",
|
||||
"batch",
|
||||
"serialNo",
|
||||
"quantity",
|
||||
"unit",
|
||||
"unitConversionFactor",
|
||||
|
@ -11,6 +11,7 @@ import InventorySettings from './app/inventory/InventorySettings.json';
|
||||
import Location from './app/inventory/Location.json';
|
||||
import PurchaseReceipt from './app/inventory/PurchaseReceipt.json';
|
||||
import PurchaseReceiptItem from './app/inventory/PurchaseReceiptItem.json';
|
||||
import SerialNo from './app/inventory/SerialNo.json';
|
||||
import Shipment from './app/inventory/Shipment.json';
|
||||
import ShipmentItem from './app/inventory/ShipmentItem.json';
|
||||
import StockLedgerEntry from './app/inventory/StockLedgerEntry.json';
|
||||
@ -117,4 +118,5 @@ export const appSchemas: Schema[] | SchemaStub[] = [
|
||||
PurchaseReceiptItem as Schema,
|
||||
|
||||
Batch as Schema,
|
||||
SerialNo as Schema,
|
||||
];
|
||||
|
@ -96,6 +96,13 @@ async function getInventorySidebar(): Promise<SidebarRoot[]> {
|
||||
name: 'stock-balance',
|
||||
route: '/report/StockBalance',
|
||||
},
|
||||
{
|
||||
label: t`Serial No`,
|
||||
name: 'serial-no',
|
||||
route: `/list/SerialNo`,
|
||||
schemaName: 'SerialNo',
|
||||
hidden: () => !fyo.singles.InventorySettings?.enableSerialNo as boolean,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
Loading…
Reference in New Issue
Block a user