2
0
mirror of https://github.com/frappe/books.git synced 2024-11-08 14:50:56 +00:00

Merge pull request #605 from akshayitzme/serial-no

feat: serial number
This commit is contained in:
Alan 2023-05-04 03:41:33 -07:00 committed by GitHub
commit e5a854c9d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 800 additions and 23 deletions

View File

@ -138,7 +138,8 @@ export class BespokeQueries {
location?: string, location?: string,
fromDate?: string, fromDate?: string,
toDate?: string, toDate?: string,
batch?: string batch?: string,
serialNo?: string
): Promise<number | null> { ): Promise<number | null> {
const query = db.knex!(ModelNameEnum.StockLedgerEntry) const query = db.knex!(ModelNameEnum.StockLedgerEntry)
.sum('quantity') .sum('quantity')
@ -152,6 +153,10 @@ export class BespokeQueries {
query.andWhere('batch', batch); query.andWhere('batch', batch);
} }
if (serialNo) {
query.andWhere('serialNo', serialNo);
}
if (fromDate) { if (fromDate) {
query.andWhereRaw('datetime(date) > datetime(?)', [fromDate]); query.andWhereRaw('datetime(date) > datetime(?)', [fromDate]);
} }

View File

@ -313,7 +313,8 @@ export class DatabaseHandler extends DatabaseBase {
location?: string, location?: string,
fromDate?: string, fromDate?: string,
toDate?: string, toDate?: string,
batch?: string batch?: string,
serialNo?: string
): Promise<number | null> { ): Promise<number | null> {
return (await this.#demux.callBespoke( return (await this.#demux.callBespoke(
'getStockQuantity', 'getStockQuantity',
@ -321,7 +322,8 @@ export class DatabaseHandler extends DatabaseBase {
location, location,
fromDate, fromDate,
toDate, toDate,
batch batch,
serialNo
)) as number | null; )) as number | null;
} }

View File

@ -19,6 +19,7 @@ export class Item extends Doc {
itemType?: 'Product' | 'Service'; itemType?: 'Product' | 'Service';
for?: 'Purchases' | 'Sales' | 'Both'; for?: 'Purchases' | 'Sales' | 'Both';
hasBatch?: boolean; hasBatch?: boolean;
hasSerialNo?: boolean;
formulas: FormulaMap = { formulas: FormulaMap = {
incomeAccount: { incomeAccount: {
@ -124,6 +125,8 @@ export class Item extends Doc {
barcode: () => !this.fyo.singles.InventorySettings?.enableBarcodes, barcode: () => !this.fyo.singles.InventorySettings?.enableBarcodes,
hasBatch: () => hasBatch: () =>
!(this.fyo.singles.InventorySettings?.enableBatches && this.trackItem), !(this.fyo.singles.InventorySettings?.enableBatches && this.trackItem),
hasSerialNo: () =>
!(this.fyo.singles.InventorySettings?.enableSerialNo && this.trackItem),
uomConversions: () => uomConversions: () =>
!this.fyo.singles.InventorySettings?.enableUomConversions, !this.fyo.singles.InventorySettings?.enableUomConversions,
}; };
@ -133,5 +136,6 @@ export class Item extends Doc {
itemType: () => this.inserted, itemType: () => this.inserted,
trackItem: () => this.inserted, trackItem: () => this.inserted,
hasBatch: () => this.inserted, hasBatch: () => this.inserted,
hasSerialNo: () => this.inserted,
}; };
} }

View File

@ -16,7 +16,7 @@ import {
import { Invoice } from './baseModels/Invoice/Invoice'; import { Invoice } from './baseModels/Invoice/Invoice';
import { StockMovement } from './inventory/StockMovement'; import { StockMovement } from './inventory/StockMovement';
import { StockTransfer } from './inventory/StockTransfer'; import { StockTransfer } from './inventory/StockTransfer';
import { InvoiceStatus, ModelNameEnum } from './types'; import { InvoiceStatus, SerialNoStatus, ModelNameEnum } from './types';
export function getInvoiceActions( export function getInvoiceActions(
fyo: Fyo, fyo: Fyo,
@ -248,6 +248,45 @@ export function getInvoiceStatus(doc: RenderData | Doc): InvoiceStatus {
return 'Saved'; 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({ export async function getExchangeRate({
fromCurrency, fromCurrency,
toCurrency, toCurrency,

View File

@ -19,6 +19,7 @@ import { SetupWizard } from './baseModels/SetupWizard/SetupWizard';
import { Tax } from './baseModels/Tax/Tax'; import { Tax } from './baseModels/Tax/Tax';
import { TaxSummary } from './baseModels/TaxSummary/TaxSummary'; import { TaxSummary } from './baseModels/TaxSummary/TaxSummary';
import { Batch } from './inventory/Batch'; import { Batch } from './inventory/Batch';
import { SerialNo } from './inventory/SerialNo';
import { InventorySettings } from './inventory/InventorySettings'; import { InventorySettings } from './inventory/InventorySettings';
import { Location } from './inventory/Location'; import { Location } from './inventory/Location';
import { PurchaseReceipt } from './inventory/PurchaseReceipt'; import { PurchaseReceipt } from './inventory/PurchaseReceipt';
@ -48,6 +49,7 @@ export const models = {
PurchaseInvoiceItem, PurchaseInvoiceItem,
SalesInvoice, SalesInvoice,
SalesInvoiceItem, SalesInvoiceItem,
SerialNo,
SetupWizard, SetupWizard,
PrintTemplate, PrintTemplate,
Tax, Tax,

View File

@ -11,6 +11,7 @@ export class InventorySettings extends Doc {
costOfGoodsSold?: string; costOfGoodsSold?: string;
enableBarcodes?: boolean; enableBarcodes?: boolean;
enableBatches?: boolean; enableBatches?: boolean;
enableSerialNo?: boolean;
enableUomConversions?: boolean; enableUomConversions?: boolean;
static filters: FiltersMap = { static filters: FiltersMap = {
@ -35,6 +36,9 @@ export class InventorySettings extends Doc {
enableBatches: () => { enableBatches: () => {
return !!this.enableBatches; return !!this.enableBatches;
}, },
enableSerialNo: () => {
return !!this.enableSerialNo;
},
enableUomConversions: () => { enableUomConversions: () => {
return !!this.enableUomConversions; return !!this.enableUomConversions;
}, },

View File

@ -1,11 +1,17 @@
import { ListViewSettings } from 'fyo/model/types'; import { ListViewSettings } from 'fyo/model/types';
import { getTransactionStatusColumn } from 'models/helpers'; import { getTransactionStatusColumn } from 'models/helpers';
import { updateSerialNoStatus } from './helpers';
import { PurchaseReceiptItem } from './PurchaseReceiptItem'; import { PurchaseReceiptItem } from './PurchaseReceiptItem';
import { StockTransfer } from './StockTransfer'; import { StockTransfer } from './StockTransfer';
export class PurchaseReceipt extends StockTransfer { export class PurchaseReceipt extends StockTransfer {
items?: PurchaseReceiptItem[]; items?: PurchaseReceiptItem[];
async afterSubmit(): Promise<void> {
await super.afterSubmit();
await updateSerialNoStatus(this, this.items!, 'Active');
}
static getListViewSettings(): ListViewSettings { static getListViewSettings(): ListViewSettings {
return { return {
columns: [ columns: [

View 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',
],
};
}
}

View File

@ -1,11 +1,22 @@
import { ListViewSettings } from 'fyo/model/types'; import { ListViewSettings } from 'fyo/model/types';
import { getTransactionStatusColumn } from 'models/helpers'; import { getTransactionStatusColumn } from 'models/helpers';
import { updateSerialNoStatus } from './helpers';
import { ShipmentItem } from './ShipmentItem'; import { ShipmentItem } from './ShipmentItem';
import { StockTransfer } from './StockTransfer'; import { StockTransfer } from './StockTransfer';
export class Shipment extends StockTransfer { export class Shipment extends StockTransfer {
items?: ShipmentItem[]; 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 { static getListViewSettings(): ListViewSettings {
return { return {
columns: [ columns: [

View File

@ -11,6 +11,7 @@ export class StockLedgerEntry extends Doc {
referenceName?: string; referenceName?: string;
referenceType?: string; referenceType?: string;
batch?: string; batch?: string;
serialNo?: string;
static override getListViewSettings(): ListViewSettings { static override getListViewSettings(): ListViewSettings {
return { return {

View File

@ -4,6 +4,7 @@ import { ModelNameEnum } from 'models/types';
import { Money } from 'pesa'; import { Money } from 'pesa';
import { StockLedgerEntry } from './StockLedgerEntry'; import { StockLedgerEntry } from './StockLedgerEntry';
import { SMDetails, SMIDetails, SMTransferDetails } from './types'; import { SMDetails, SMIDetails, SMTransferDetails } from './types';
import { getSerialNumbers } from './helpers';
export class StockManager { export class StockManager {
/** /**
@ -133,15 +134,33 @@ export class StockManager {
const date = details.date.toISOString(); const date = details.date.toISOString();
const formattedDate = this.fyo.format(details.date, 'Datetime'); const formattedDate = this.fyo.format(details.date, 'Datetime');
const batch = details.batch || undefined; const batch = details.batch || undefined;
const serialNo = details.serialNo || undefined;
let quantityBefore = 0;
let quantityBefore = if (serialNo) {
(await this.fyo.db.getStockQuantity( const serialNos = getSerialNumbers(serialNo);
details.item, for (const serialNo of serialNos) {
details.fromLocation, quantityBefore +=
undefined, (await this.fyo.db.getStockQuantity(
date, details.item,
batch details.fromLocation,
)) ?? 0; undefined,
date,
batch,
serialNo
)) ?? 0;
}
} else {
quantityBefore =
(await this.fyo.db.getStockQuantity(
details.item,
details.fromLocation,
undefined,
date,
batch,
serialNo
)) ?? 0;
}
if (this.isCancelled) { if (this.isCancelled) {
quantityBefore += details.quantity; quantityBefore += details.quantity;
@ -167,7 +186,8 @@ export class StockManager {
details.fromLocation, details.fromLocation,
details.date.toISOString(), details.date.toISOString(),
undefined, undefined,
batch batch,
serialNo
); );
if (quantityAfter === null) { if (quantityAfter === null) {
@ -211,6 +231,7 @@ class StockManagerItem {
fromLocation?: string; fromLocation?: string;
toLocation?: string; toLocation?: string;
batch?: string; batch?: string;
serialNo?: string;
stockLedgerEntries?: StockLedgerEntry[]; stockLedgerEntries?: StockLedgerEntry[];
@ -226,6 +247,7 @@ class StockManagerItem {
this.referenceName = details.referenceName; this.referenceName = details.referenceName;
this.referenceType = details.referenceType; this.referenceType = details.referenceType;
this.batch = details.batch; this.batch = details.batch;
this.serialNo = details.serialNo;
this.fyo = fyo; this.fyo = fyo;
} }
@ -260,10 +282,30 @@ class StockManagerItem {
#moveStockForSingleLocation(location: string, isOutward: boolean) { #moveStockForSingleLocation(location: string, isOutward: boolean) {
let quantity = this.quantity!; let quantity = this.quantity!;
const serialNo = this.serialNo;
if (quantity === 0) { if (quantity === 0) {
return; 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) { if (isOutward) {
quantity = -quantity; quantity = -quantity;
} }
@ -273,12 +315,13 @@ class StockManagerItem {
this.stockLedgerEntries?.push(stockLedgerEntry); this.stockLedgerEntries?.push(stockLedgerEntry);
} }
#getStockLedgerEntry(location: string, quantity: number) { #getStockLedgerEntry(location: string, quantity: number, serialNo?: string) {
return this.fyo.doc.getNewDoc(ModelNameEnum.StockLedgerEntry, { return this.fyo.doc.getNewDoc(ModelNameEnum.StockLedgerEntry, {
date: this.date, date: this.date,
item: this.item, item: this.item,
rate: this.rate, rate: this.rate,
batch: this.batch || null, batch: this.batch || null,
serialNo: serialNo || null,
quantity, quantity,
location, location,
referenceName: this.referenceName, referenceName: this.referenceName,

View File

@ -15,7 +15,12 @@ import {
import { LedgerPosting } from 'models/Transactional/LedgerPosting'; import { LedgerPosting } from 'models/Transactional/LedgerPosting';
import { ModelNameEnum } from 'models/types'; import { ModelNameEnum } from 'models/types';
import { Money } from 'pesa'; import { Money } from 'pesa';
import { validateBatch } from './helpers'; import {
getSerialNumbers,
updateSerialNoStatus,
validateBatch,
validateSerialNo,
} from './helpers';
import { StockMovementItem } from './StockMovementItem'; import { StockMovementItem } from './StockMovementItem';
import { Transfer } from './Transfer'; import { Transfer } from './Transfer';
import { MovementType } from './types'; import { MovementType } from './types';
@ -52,6 +57,7 @@ export class StockMovement extends Transfer {
await super.validate(); await super.validate();
this.validateManufacture(); this.validateManufacture();
await validateBatch(this); await validateBatch(this);
await validateSerialNo(this);
} }
validateManufacture() { 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 = { static filters: FiltersMap = {
numberSeries: () => ({ referenceType: ModelNameEnum.StockMovement }), numberSeries: () => ({ referenceType: ModelNameEnum.StockMovement }),
}; };
@ -110,6 +126,7 @@ export class StockMovement extends Transfer {
rate: row.rate!, rate: row.rate!,
quantity: row.quantity!, quantity: row.quantity!,
batch: row.batch!, batch: row.batch!,
serialNo: row.serialNo!,
fromLocation: row.fromLocation, fromLocation: row.fromLocation,
toLocation: row.toLocation, toLocation: row.toLocation,
})); }));

View File

@ -32,6 +32,7 @@ export class StockMovementItem extends Doc {
amount?: Money; amount?: Money;
parentdoc?: StockMovement; parentdoc?: StockMovement;
batch?: string; batch?: string;
serialNo?: string;
get isIssue() { get isIssue() {
return this.parentdoc?.movementType === MovementType.MaterialIssue; return this.parentdoc?.movementType === MovementType.MaterialIssue;
@ -236,6 +237,7 @@ export class StockMovementItem extends Doc {
override hidden: HiddenMap = { override hidden: HiddenMap = {
batch: () => !this.fyo.singles.InventorySettings?.enableBatches, batch: () => !this.fyo.singles.InventorySettings?.enableBatches,
serialNo: () => !this.fyo.singles.InventorySettings?.enableSerialNo,
transferUnit: () => transferUnit: () =>
!this.fyo.singles.InventorySettings?.enableUomConversions, !this.fyo.singles.InventorySettings?.enableUomConversions,
transferQuantity: () => transferQuantity: () =>

View File

@ -17,7 +17,7 @@ import { LedgerPosting } from 'models/Transactional/LedgerPosting';
import { ModelNameEnum } from 'models/types'; import { ModelNameEnum } from 'models/types';
import { Money } from 'pesa'; import { Money } from 'pesa';
import { TargetField } from 'schemas/types'; import { TargetField } from 'schemas/types';
import { validateBatch } from './helpers'; import { validateBatch, validateSerialNo } from './helpers';
import { StockTransferItem } from './StockTransferItem'; import { StockTransferItem } from './StockTransferItem';
import { Transfer } from './Transfer'; import { Transfer } from './Transfer';
@ -91,6 +91,7 @@ export abstract class StockTransfer extends Transfer {
rate: row.rate!, rate: row.rate!,
quantity: row.quantity!, quantity: row.quantity!,
batch: row.batch!, batch: row.batch!,
serialNo: row.serialNo!,
fromLocation, fromLocation,
toLocation, toLocation,
}; };
@ -162,6 +163,7 @@ export abstract class StockTransfer extends Transfer {
override async validate(): Promise<void> { override async validate(): Promise<void> {
await super.validate(); await super.validate();
await validateBatch(this); await validateBatch(this);
await validateSerialNo(this);
} }
static getActions(fyo: Fyo): Action[] { static getActions(fyo: Fyo): Action[] {

View File

@ -28,6 +28,7 @@ export class StockTransferItem extends Doc {
description?: string; description?: string;
hsnCode?: number; hsnCode?: number;
batch?: string; batch?: string;
serialNo?: string;
parentdoc?: StockTransfer; parentdoc?: StockTransfer;
@ -215,6 +216,7 @@ export class StockTransferItem extends Doc {
override hidden: HiddenMap = { override hidden: HiddenMap = {
batch: () => !this.fyo.singles.InventorySettings?.enableBatches, batch: () => !this.fyo.singles.InventorySettings?.enableBatches,
serialNo: () => !this.fyo.singles.InventorySettings?.enableSerialNo,
transferUnit: () => transferUnit: () =>
!this.fyo.singles.InventorySettings?.enableUomConversions, !this.fyo.singles.InventorySettings?.enableUomConversions,
transferQuantity: () => transferQuantity: () =>

View File

@ -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 { ValidationError } from 'fyo/utils/errors';
import { Invoice } from 'models/baseModels/Invoice/Invoice'; import { Invoice } from 'models/baseModels/Invoice/Invoice';
import { InvoiceItem } from 'models/baseModels/InvoiceItem/InvoiceItem'; import { InvoiceItem } from 'models/baseModels/InvoiceItem/InvoiceItem';
import { ModelNameEnum } from 'models/types'; import { ModelNameEnum } from 'models/types';
import { ShipmentItem } from './ShipmentItem';
import { StockMovement } from './StockMovement'; import { StockMovement } from './StockMovement';
import { StockMovementItem } from './StockMovementItem'; import { StockMovementItem } from './StockMovementItem';
import { StockTransfer } from './StockTransfer'; import { StockTransfer } from './StockTransfer';
@ -25,11 +29,7 @@ async function validateItemRowBatch(
return; return;
} }
const hasBatch = await doc.fyo.getValue( const hasBatch = await doc.fyo.getValue(ModelNameEnum.Item, item, 'hasBatch');
ModelNameEnum.Item,
item,
'hasBatch'
);
if (!hasBatch && batch) { if (!hasBatch && batch) {
throw new ValidationError( 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`);
};

View File

@ -28,6 +28,7 @@ type Transfer = {
from?: string; from?: string;
to?: string; to?: string;
batch?: string; batch?: string;
serialNo?: string;
quantity: number; quantity: number;
rate: number; rate: number;
}; };
@ -36,8 +37,8 @@ interface TransferTwo extends Omit<Transfer, 'from' | 'to'> {
location: string; location: string;
} }
export function getItem(name: string, rate: number, hasBatch: boolean = false) { export function getItem(name: string, rate: number, hasBatch: boolean = false, hasSerialNo: boolean = false) {
return { name, rate, trackItem: true, hasBatch }; return { name, rate, trackItem: true, hasBatch, hasSerialNo };
} }
export async function getBatch( export async function getBatch(
@ -84,6 +85,7 @@ export async function getStockMovement(
from: fromLocation, from: fromLocation,
to: toLocation, to: toLocation,
batch, batch,
serialNo,
quantity, quantity,
rate, rate,
} of transfers) { } of transfers) {
@ -92,6 +94,7 @@ export async function getStockMovement(
fromLocation, fromLocation,
toLocation, toLocation,
batch, batch,
serialNo,
rate, rate,
quantity, quantity,
}); });

View 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);

View File

@ -23,6 +23,7 @@ export interface SMTransferDetails {
rate: Money; rate: Money;
quantity: number; quantity: number;
batch?: string; batch?: string;
serialNo?: string;
fromLocation?: string; fromLocation?: string;
toLocation?: string; toLocation?: string;
} }

View File

@ -1,4 +1,5 @@
export type InvoiceStatus = 'Draft' | 'Saved' | 'Unpaid' | 'Cancelled' | 'Paid'; export type InvoiceStatus = 'Draft' | 'Saved' | 'Unpaid' | 'Cancelled' | 'Paid';
export type SerialNoStatus = 'Inactive' | 'Active' | 'Delivered' | 'Expired';
export enum ModelNameEnum { export enum ModelNameEnum {
Account = 'Account', Account = 'Account',
AccountingLedgerEntry = 'AccountingLedgerEntry', AccountingLedgerEntry = 'AccountingLedgerEntry',
@ -25,6 +26,7 @@ export enum ModelNameEnum {
PurchaseInvoiceItem = 'PurchaseInvoiceItem', PurchaseInvoiceItem = 'PurchaseInvoiceItem',
SalesInvoice = 'SalesInvoice', SalesInvoice = 'SalesInvoice',
SalesInvoiceItem = 'SalesInvoiceItem', SalesInvoiceItem = 'SalesInvoiceItem',
SerialNo = 'SerialNo',
SetupWizard = 'SetupWizard', SetupWizard = 'SetupWizard',
Tax = 'Tax', Tax = 'Tax',
TaxDetail = 'TaxDetail', TaxDetail = 'TaxDetail',

View File

@ -28,6 +28,7 @@ export class StockLedger extends Report {
item?: string; item?: string;
location?: string; location?: string;
batch?: string; batch?: string;
serialNo?: string;
fromDate?: string; fromDate?: string;
toDate?: string; toDate?: string;
ascending?: boolean; ascending?: boolean;
@ -41,6 +42,11 @@ export class StockLedger extends Report {
.enableBatches; .enableBatches;
} }
get hasSerialNos(): boolean {
return !!(this.fyo.singles.InventorySettings as InventorySettings)
.enableSerialNo;
}
constructor(fyo: Fyo) { constructor(fyo: Fyo) {
super(fyo); super(fyo);
this._setObservers(); this._setObservers();
@ -276,6 +282,11 @@ export class StockLedger extends Report {
{ fieldname: 'batch', label: 'Batch', fieldtype: 'Link' }, { fieldname: 'batch', label: 'Batch', fieldtype: 'Link' },
] as ColumnField[]) ] as ColumnField[])
: []), : []),
...(this.hasSerialNos
? ([
{ fieldname: 'serialNo', label: 'Serial No', fieldtype: 'Data' },
] as ColumnField[])
: []),
{ {
fieldname: 'quantity', fieldname: 'quantity',
label: 'Quantity', label: 'Quantity',

View File

@ -19,6 +19,7 @@ export async function getRawStockLedgerEntries(fyo: Fyo) {
'date', 'date',
'item', 'item',
'batch', 'batch',
'serialNo',
'rate', 'rate',
'quantity', 'quantity',
'location', 'location',
@ -49,6 +50,7 @@ export function getStockLedgerEntries(
const rate = safeParseFloat(sle.rate); const rate = safeParseFloat(sle.rate);
const { item, location, quantity, referenceName, referenceType } = sle; const { item, location, quantity, referenceName, referenceType } = sle;
const batch = sle.batch ?? ''; const batch = sle.batch ?? '';
const serialNo = sle.serialNo ?? '';
if (quantity === 0) { if (quantity === 0) {
continue; continue;
@ -88,6 +90,7 @@ export function getStockLedgerEntries(
item, item,
location, location,
batch, batch,
serialNo,
quantity, quantity,
balanceQuantity, balanceQuantity,

View File

@ -6,6 +6,7 @@ export interface RawStockLedgerEntry {
item: string; item: string;
rate: string; rate: string;
batch: string | null; batch: string | null;
serialNo: string | null;
quantity: number; quantity: number;
location: string; location: string;
referenceName: string; referenceName: string;
@ -21,6 +22,7 @@ export interface ComputedStockLedgerEntry{
item: string; item: string;
location:string; location:string;
batch: string; batch: string;
serialNo: string;
quantity: number; quantity: number;
balanceQuantity: number; balanceQuantity: number;

View File

@ -138,6 +138,13 @@
"default": false, "default": false,
"section": "Inventory" "section": "Inventory"
}, },
{
"fieldname": "hasSerialNo",
"label": "Has Serial No",
"fieldtype": "Check",
"default": false,
"section": "Inventory"
},
{ {
"fieldname": "uomConversions", "fieldname": "uomConversions",
"label": "UOM Conversions", "label": "UOM Conversions",

View File

@ -63,6 +63,12 @@
"fieldtype": "Check", "fieldtype": "Check",
"section": "Features" "section": "Features"
}, },
{
"fieldname": "enableSerialNo",
"label": "Enable Serial No",
"fieldtype": "Check",
"section": "Features"
},
{ {
"fieldname": "enableUomConversions", "fieldname": "enableUomConversions",
"label": "Enable UOM Conversion", "label": "Enable UOM Conversion",

View 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"]
}

View File

@ -37,6 +37,14 @@
"readOnly": true, "readOnly": true,
"section": "Details" "section": "Details"
}, },
{
"fieldname": "serialNo",
"label": "Serial No",
"fieldtype": "Link",
"target": "SerialNo",
"readOnly": true,
"section": "Details"
},
{ {
"fieldname": "item", "fieldname": "item",
"label": "Item", "label": "Item",

View File

@ -57,6 +57,11 @@
"target": "Batch", "target": "Batch",
"create": true "create": true
}, },
{
"fieldname": "serialNo",
"label": "Serial No",
"fieldtype": "Text"
},
{ {
"fieldname": "quantity", "fieldname": "quantity",
"label": "Quantity", "label": "Quantity",
@ -94,6 +99,7 @@
"transferQuantity", "transferQuantity",
"transferUnit", "transferUnit",
"batch", "batch",
"serialNo",
"quantity", "quantity",
"unit", "unit",
"unitConversionFactor", "unitConversionFactor",

View File

@ -47,6 +47,11 @@
"fieldtype": "Link", "fieldtype": "Link",
"target": "Batch" "target": "Batch"
}, },
{
"fieldname": "serialNo",
"label": "Serial No",
"fieldtype": "Text"
},
{ {
"fieldname": "quantity", "fieldname": "quantity",
"label": "Quantity", "label": "Quantity",
@ -93,6 +98,7 @@
"transferQuantity", "transferQuantity",
"transferUnit", "transferUnit",
"batch", "batch",
"serialNo",
"quantity", "quantity",
"unit", "unit",
"unitConversionFactor", "unitConversionFactor",

View File

@ -11,6 +11,7 @@ import InventorySettings from './app/inventory/InventorySettings.json';
import Location from './app/inventory/Location.json'; import Location from './app/inventory/Location.json';
import PurchaseReceipt from './app/inventory/PurchaseReceipt.json'; import PurchaseReceipt from './app/inventory/PurchaseReceipt.json';
import PurchaseReceiptItem from './app/inventory/PurchaseReceiptItem.json'; import PurchaseReceiptItem from './app/inventory/PurchaseReceiptItem.json';
import SerialNo from './app/inventory/SerialNo.json';
import Shipment from './app/inventory/Shipment.json'; import Shipment from './app/inventory/Shipment.json';
import ShipmentItem from './app/inventory/ShipmentItem.json'; import ShipmentItem from './app/inventory/ShipmentItem.json';
import StockLedgerEntry from './app/inventory/StockLedgerEntry.json'; import StockLedgerEntry from './app/inventory/StockLedgerEntry.json';
@ -117,4 +118,5 @@ export const appSchemas: Schema[] | SchemaStub[] = [
PurchaseReceiptItem as Schema, PurchaseReceiptItem as Schema,
Batch as Schema, Batch as Schema,
SerialNo as Schema,
]; ];

View File

@ -96,6 +96,13 @@ async function getInventorySidebar(): Promise<SidebarRoot[]> {
name: 'stock-balance', name: 'stock-balance',
route: '/report/StockBalance', route: '/report/StockBalance',
}, },
{
label: t`Serial No`,
name: 'serial-no',
route: `/list/SerialNo`,
schemaName: 'SerialNo',
hidden: () => !fyo.singles.InventorySettings?.enableSerialNo as boolean,
},
], ],
}, },
]; ];