mirror of
https://github.com/frappe/books.git
synced 2024-12-23 11:29:03 +00:00
Merge pull request #525 from wahni-green/batch-wise-item
feat: batch-wise inventory
This commit is contained in:
commit
35179ec2eb
@ -137,7 +137,8 @@ export class BespokeQueries {
|
||||
item: string,
|
||||
location?: string,
|
||||
fromDate?: string,
|
||||
toDate?: string
|
||||
toDate?: string,
|
||||
batchNumber?: string
|
||||
): Promise<number | null> {
|
||||
const query = db.knex!(ModelNameEnum.StockLedgerEntry)
|
||||
.sum('quantity')
|
||||
@ -147,6 +148,10 @@ export class BespokeQueries {
|
||||
query.andWhere('location', location);
|
||||
}
|
||||
|
||||
if (batchNumber) {
|
||||
query.andWhere('batchNumber', batchNumber);
|
||||
}
|
||||
|
||||
if (fromDate) {
|
||||
query.andWhereRaw('datetime(date) > datetime(?)', [fromDate]);
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ import {
|
||||
DatabaseBase,
|
||||
DatabaseDemuxBase,
|
||||
GetAllOptions,
|
||||
QueryFilter
|
||||
QueryFilter,
|
||||
} from 'utils/db/types';
|
||||
import { schemaTranslateables } from 'utils/translationHelpers';
|
||||
import { LanguageMap } from 'utils/types';
|
||||
@ -19,7 +19,7 @@ import {
|
||||
DatabaseDemuxConstructor,
|
||||
DocValue,
|
||||
DocValueMap,
|
||||
RawValueMap
|
||||
RawValueMap,
|
||||
} from './types';
|
||||
|
||||
// Return types of Bespoke Queries
|
||||
@ -312,14 +312,16 @@ export class DatabaseHandler extends DatabaseBase {
|
||||
item: string,
|
||||
location?: string,
|
||||
fromDate?: string,
|
||||
toDate?: string
|
||||
toDate?: string,
|
||||
batchNumber?: string
|
||||
): Promise<number | null> {
|
||||
return (await this.#demux.callBespoke(
|
||||
'getStockQuantity',
|
||||
item,
|
||||
location,
|
||||
fromDate,
|
||||
toDate
|
||||
toDate,
|
||||
batchNumber
|
||||
)) as number | null;
|
||||
}
|
||||
|
||||
|
13
models/baseModels/BatchNumber/BatchNumber.ts
Normal file
13
models/baseModels/BatchNumber/BatchNumber.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Doc } from 'fyo/model/doc';
|
||||
import {
|
||||
ListViewSettings,
|
||||
} from 'fyo/model/types';
|
||||
|
||||
export class BatchNumber extends Doc {
|
||||
static getListViewSettings(): ListViewSettings {
|
||||
return {
|
||||
columns: ["name", "expiryDate", "manufactureDate"],
|
||||
};
|
||||
}
|
||||
|
||||
}
|
@ -11,6 +11,7 @@ import {
|
||||
ValidationMap,
|
||||
} from 'fyo/model/types';
|
||||
import { ValidationError } from 'fyo/utils/errors';
|
||||
import { ModelNameEnum } from 'models/types';
|
||||
import { Money } from 'pesa';
|
||||
import { AccountRootTypeEnum, AccountTypeEnum } from '../Account/types';
|
||||
|
||||
@ -18,6 +19,7 @@ export class Item extends Doc {
|
||||
trackItem?: boolean;
|
||||
itemType?: 'Product' | 'Service';
|
||||
for?: 'Purchases' | 'Sales' | 'Both';
|
||||
hasBatchNumber?: boolean;
|
||||
|
||||
formulas: FormulaMap = {
|
||||
incomeAccount: {
|
||||
@ -74,6 +76,29 @@ export class Item extends Doc {
|
||||
throw new ValidationError(this.fyo.t`Rate can't be negative.`);
|
||||
}
|
||||
},
|
||||
hasBatchNumber: async (value: DocValue) => {
|
||||
const { hasBatchNumber } = await this.fyo.db.get(
|
||||
'Item',
|
||||
this.name!,
|
||||
'hasBatchNumber'
|
||||
);
|
||||
|
||||
if (hasBatchNumber && value !== hasBatchNumber) {
|
||||
const itemEntriesInSLE = await this.fyo.db.count(
|
||||
ModelNameEnum.StockLedgerEntry,
|
||||
{
|
||||
filters: { item: this.name! },
|
||||
}
|
||||
);
|
||||
|
||||
if (itemEntriesInSLE > 0) {
|
||||
throw new ValidationError(
|
||||
this.fyo.t`Cannot change value of Has Batch No as Item ${this
|
||||
.name!} already has transactions against it. `
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
static getActions(fyo: Fyo): Action[] {
|
||||
@ -121,6 +146,7 @@ export class Item extends Doc {
|
||||
!this.fyo.singles.AccountingSettings?.enableInventory ||
|
||||
this.itemType !== 'Product' ||
|
||||
(this.inserted && !this.trackItem),
|
||||
hasBatchNumber: () => !this.trackItem,
|
||||
barcode: () => !this.fyo.singles.InventorySettings?.enableBarcodes,
|
||||
uomConversions: () => !this.fyo.singles.AccountingSettings?.enableInventory,
|
||||
};
|
||||
@ -129,5 +155,6 @@ export class Item extends Doc {
|
||||
unit: () => this.inserted,
|
||||
itemType: () => this.inserted,
|
||||
trackItem: () => this.inserted,
|
||||
hasBatchNumber: () => this.inserted,
|
||||
};
|
||||
}
|
||||
|
@ -26,12 +26,14 @@ import { ShipmentItem } from './inventory/ShipmentItem';
|
||||
import { StockLedgerEntry } from './inventory/StockLedgerEntry';
|
||||
import { StockMovement } from './inventory/StockMovement';
|
||||
import { StockMovementItem } from './inventory/StockMovementItem';
|
||||
import { BatchNumber } from './baseModels/BatchNumber/BatchNumber';
|
||||
|
||||
export const models = {
|
||||
Account,
|
||||
AccountingLedgerEntry,
|
||||
AccountingSettings,
|
||||
Address,
|
||||
BatchNumber,
|
||||
Defaults,
|
||||
Item,
|
||||
JournalEntry,
|
||||
|
@ -10,4 +10,5 @@ export class StockLedgerEntry extends Doc {
|
||||
location?: string;
|
||||
referenceName?: string;
|
||||
referenceType?: string;
|
||||
batchNumber?: string;
|
||||
}
|
||||
|
@ -132,16 +132,54 @@ export class StockManager {
|
||||
}
|
||||
|
||||
const date = details.date.toISOString();
|
||||
const formattedDate = this.fyo.format(details.date, 'Datetime');
|
||||
const isItemHasBatchNumber = await this.fyo.getValue(
|
||||
'Item',
|
||||
details.item,
|
||||
'hasBatchNumber'
|
||||
);
|
||||
|
||||
if (isItemHasBatchNumber && !this.isCancelled) {
|
||||
if (!details.batchNumber) {
|
||||
throw new ValidationError(
|
||||
t`Please enter Batch Number for ${details.item}`
|
||||
);
|
||||
}
|
||||
|
||||
const itemsInBatch =
|
||||
(await this.fyo.db.getStockQuantity(
|
||||
details.item,
|
||||
details.fromLocation,
|
||||
undefined,
|
||||
date,
|
||||
details.batchNumber
|
||||
)) ?? 0;
|
||||
|
||||
if (details.quantity <= itemsInBatch) return;
|
||||
|
||||
throw new ValidationError(
|
||||
[
|
||||
t`Insufficient Quantity`,
|
||||
t`Additional quantity (${
|
||||
details.quantity - itemsInBatch
|
||||
}) is required in batch ${
|
||||
details.batchNumber
|
||||
} to make the outward transfer of item ${details.item} from ${
|
||||
details.fromLocation
|
||||
} on ${formattedDate}`,
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
let quantityBefore =
|
||||
(await this.fyo.db.getStockQuantity(
|
||||
details.item,
|
||||
details.fromLocation,
|
||||
undefined,
|
||||
date
|
||||
date,
|
||||
undefined
|
||||
)) ?? 0;
|
||||
|
||||
const formattedDate = this.fyo.format(details.date, 'Datetime');
|
||||
|
||||
if (this.isCancelled) {
|
||||
quantityBefore += details.quantity;
|
||||
}
|
||||
@ -162,7 +200,9 @@ export class StockManager {
|
||||
const quantityAfter = await this.fyo.db.getStockQuantity(
|
||||
details.item,
|
||||
details.fromLocation,
|
||||
details.date.toISOString()
|
||||
details.date.toISOString(),
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
if (quantityAfter === null) {
|
||||
// No future transactions
|
||||
@ -204,6 +244,7 @@ class StockManagerItem {
|
||||
referenceType: string;
|
||||
fromLocation?: string;
|
||||
toLocation?: string;
|
||||
batchNumber?: string;
|
||||
|
||||
stockLedgerEntries?: StockLedgerEntry[];
|
||||
|
||||
@ -218,6 +259,7 @@ class StockManagerItem {
|
||||
this.toLocation = details.toLocation;
|
||||
this.referenceName = details.referenceName;
|
||||
this.referenceType = details.referenceType;
|
||||
this.batchNumber = details.batchNumber;
|
||||
|
||||
this.fyo = fyo;
|
||||
}
|
||||
@ -270,6 +312,7 @@ class StockManagerItem {
|
||||
date: this.date,
|
||||
item: this.item,
|
||||
rate: this.rate,
|
||||
batchNumber: this.batchNumber,
|
||||
quantity,
|
||||
location,
|
||||
referenceName: this.referenceName,
|
||||
|
@ -109,6 +109,7 @@ export class StockMovement extends Transfer {
|
||||
item: row.item!,
|
||||
rate: row.rate!,
|
||||
quantity: row.quantity!,
|
||||
batchNumber: row.batchNumber!,
|
||||
fromLocation: row.fromLocation,
|
||||
toLocation: row.toLocation,
|
||||
}));
|
||||
|
@ -30,6 +30,7 @@ export class StockMovementItem extends Doc {
|
||||
rate?: Money;
|
||||
amount?: Money;
|
||||
parentdoc?: StockMovement;
|
||||
batchNumber?: string;
|
||||
|
||||
get isIssue() {
|
||||
return this.parentdoc?.movementType === MovementType.MaterialIssue;
|
||||
|
@ -82,6 +82,7 @@ export abstract class StockTransfer extends Transfer {
|
||||
item: row.item!,
|
||||
rate: row.rate!,
|
||||
quantity: row.quantity!,
|
||||
batchNumber: row.batchNumber!,
|
||||
fromLocation,
|
||||
toLocation,
|
||||
};
|
||||
|
@ -21,6 +21,7 @@ export class StockTransferItem extends Doc {
|
||||
amount?: Money;
|
||||
description?: string;
|
||||
hsnCode?: number;
|
||||
batchNumber?: string
|
||||
|
||||
formulas: FormulaMap = {
|
||||
description: {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Fyo } from 'fyo';
|
||||
import { BatchNumber } from 'models/baseModels/BatchNumber/BatchNumber';
|
||||
import { ModelNameEnum } from 'models/types';
|
||||
import { StockMovement } from '../StockMovement';
|
||||
import { StockTransfer } from '../StockTransfer';
|
||||
@ -26,6 +27,7 @@ type Transfer = {
|
||||
item: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
batchNumber?: string;
|
||||
quantity: number;
|
||||
rate: number;
|
||||
};
|
||||
@ -34,8 +36,23 @@ interface TransferTwo extends Omit<Transfer, 'from' | 'to'> {
|
||||
location: string;
|
||||
}
|
||||
|
||||
export function getItem(name: string, rate: number) {
|
||||
return { name, rate, trackItem: true };
|
||||
export function getItem(name: string, rate: number, hasBatchNumber?: boolean) {
|
||||
return { name, rate, trackItem: true, hasBatchNumber };
|
||||
}
|
||||
|
||||
export async function getBatchNumber(
|
||||
schemaName: ModelNameEnum.BatchNumber,
|
||||
batchNumber: string,
|
||||
expiryDate: Date,
|
||||
manufactureDate: Date,
|
||||
fyo: Fyo
|
||||
): Promise<BatchNumber> {
|
||||
const doc = fyo.doc.getNewDoc(schemaName, {
|
||||
batchNumber,
|
||||
expiryDate,
|
||||
manufactureDate,
|
||||
}) as BatchNumber;
|
||||
return doc;
|
||||
}
|
||||
|
||||
export async function getStockTransfer(
|
||||
@ -62,11 +79,11 @@ export async function getStockMovement(
|
||||
movementType,
|
||||
date,
|
||||
}) as StockMovement;
|
||||
|
||||
for (const {
|
||||
item,
|
||||
from: fromLocation,
|
||||
to: toLocation,
|
||||
batchNumber: batchNumber,
|
||||
quantity,
|
||||
rate,
|
||||
} of transfers) {
|
||||
@ -74,6 +91,7 @@ export async function getStockMovement(
|
||||
item,
|
||||
fromLocation,
|
||||
toLocation,
|
||||
batchNumber,
|
||||
rate,
|
||||
quantity,
|
||||
});
|
||||
|
@ -1,6 +1,6 @@
|
||||
import {
|
||||
assertDoesNotThrow,
|
||||
assertThrows
|
||||
assertThrows,
|
||||
} from 'backend/database/tests/helpers';
|
||||
import { ModelNameEnum } from 'models/types';
|
||||
import { default as tape, default as test } from 'tape';
|
||||
@ -17,10 +17,12 @@ const itemMap = {
|
||||
Pen: {
|
||||
name: 'Pen',
|
||||
rate: 700,
|
||||
hasBatchNumber: true,
|
||||
},
|
||||
Ink: {
|
||||
name: 'Ink',
|
||||
rate: 50,
|
||||
hasBatchNumber: false,
|
||||
},
|
||||
};
|
||||
|
||||
@ -29,14 +31,22 @@ const locationMap = {
|
||||
LocationTwo: 'LocationTwo',
|
||||
};
|
||||
|
||||
const batchNumberMap = {
|
||||
batchNumberOne: {
|
||||
name: 'IK-AB001',
|
||||
manufactureDate: '2022-11-03T09:57:04.528',
|
||||
expiryDate: '2023-11-03T09:57:04.528',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Section 1: Test Creation of Items and Locations
|
||||
*/
|
||||
|
||||
test('create dummy items & locations', async (t) => {
|
||||
// Create Items
|
||||
for (const { name, rate } of Object.values(itemMap)) {
|
||||
const item = getItem(name, rate);
|
||||
for (const { name, rate, hasBatchNumber } of Object.values(itemMap)) {
|
||||
const item = getItem(name, rate, hasBatchNumber);
|
||||
await fyo.doc.getNewDoc(ModelNameEnum.Item, item).sync();
|
||||
t.ok(await fyo.db.exists(ModelNameEnum.Item, name), `${name} exists`);
|
||||
}
|
||||
@ -48,6 +58,25 @@ test('create dummy items & locations', async (t) => {
|
||||
}
|
||||
});
|
||||
|
||||
test('create dummy batch numbers', async (t) => {
|
||||
// create batchNumber
|
||||
for (const { name, manufactureDate, expiryDate } of Object.values(
|
||||
batchNumberMap
|
||||
)) {
|
||||
await fyo.doc
|
||||
.getNewDoc(ModelNameEnum.BatchNumber, {
|
||||
name,
|
||||
expiryDate,
|
||||
manufactureDate,
|
||||
})
|
||||
.sync();
|
||||
t.ok(
|
||||
await fyo.db.exists(ModelNameEnum.BatchNumber, name),
|
||||
`${name} exists`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Section 2: Test Creation of Stock Movements
|
||||
*/
|
||||
@ -90,6 +119,55 @@ test('create stock movement, material receipt', async (t) => {
|
||||
t.equal(await fyo.db.getStockQuantity(itemMap.Ink.name), quantity);
|
||||
});
|
||||
|
||||
test('Batch Enabled : create stock movement, material receipt', async (t) => {
|
||||
const { rate } = itemMap.Pen;
|
||||
const quantity = 2;
|
||||
const batchNumber = batchNumberMap.batchNumberOne.name;
|
||||
const amount = rate * quantity;
|
||||
const stockMovement = await getStockMovement(
|
||||
MovementType.MaterialReceipt,
|
||||
new Date('2022-11-03T09:57:04.528'),
|
||||
[
|
||||
{
|
||||
item: itemMap.Pen.name,
|
||||
to: locationMap.LocationOne,
|
||||
quantity,
|
||||
batchNumber,
|
||||
rate,
|
||||
},
|
||||
],
|
||||
fyo
|
||||
);
|
||||
|
||||
await (await stockMovement.sync()).submit();
|
||||
t.ok(stockMovement.name?.startsWith('SMOV-'));
|
||||
t.equal(stockMovement.amount?.float, amount);
|
||||
t.equal(stockMovement.items?.[0].amount?.float, amount);
|
||||
|
||||
const name = stockMovement.name!;
|
||||
|
||||
const sles = await getSLEs(name, ModelNameEnum.StockMovement, fyo);
|
||||
t.equal(sles.length, 1);
|
||||
|
||||
const sle = sles[0];
|
||||
t.notEqual(new Date(sle.date).toString(), 'Invalid Date');
|
||||
t.equal(parseInt(sle.name), 2);
|
||||
t.equal(sle.item, itemMap.Pen.name);
|
||||
t.equal(parseFloat(sle.rate), rate);
|
||||
t.equal(sle.quantity, quantity);
|
||||
t.equal(sle.location, locationMap.LocationOne);
|
||||
t.equal(
|
||||
await fyo.db.getStockQuantity(
|
||||
itemMap.Pen.name,
|
||||
locationMap.LocationOne,
|
||||
undefined,
|
||||
undefined,
|
||||
batchNumber
|
||||
),
|
||||
quantity
|
||||
);
|
||||
});
|
||||
|
||||
test('create stock movement, material transfer', async (t) => {
|
||||
const { rate } = itemMap.Ink;
|
||||
const quantity = 2;
|
||||
@ -136,6 +214,69 @@ test('create stock movement, material transfer', async (t) => {
|
||||
t.equal(await fyo.db.getStockQuantity(itemMap.Ink.name), quantity);
|
||||
});
|
||||
|
||||
test('Batch Enabled create stock movement, material transfer', async (t) => {
|
||||
const { rate } = itemMap.Pen;
|
||||
const quantity = 2;
|
||||
const batchNumber = batchNumberMap.batchNumberOne.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,
|
||||
batchNumber,
|
||||
quantity,
|
||||
rate,
|
||||
},
|
||||
],
|
||||
fyo
|
||||
);
|
||||
|
||||
await (await stockMovement.sync()).submit();
|
||||
const name = stockMovement.name!;
|
||||
|
||||
const sles = await getSLEs(name, ModelNameEnum.StockMovement, fyo);
|
||||
t.equal(sles.length, 2);
|
||||
|
||||
for (const sle of sles) {
|
||||
t.notEqual(new Date(sle.date).toString(), 'Invalid Date');
|
||||
t.equal(sle.item, itemMap.Pen.name);
|
||||
t.equal(parseFloat(sle.rate), rate);
|
||||
|
||||
if (sle.location === locationMap.LocationOne) {
|
||||
t.equal(sle.quantity, -quantity);
|
||||
} else if (sle.location === locationMap.LocationTwo) {
|
||||
t.equal(sle.quantity, quantity);
|
||||
} else {
|
||||
t.ok(false, 'no-op');
|
||||
}
|
||||
}
|
||||
|
||||
t.equal(
|
||||
await fyo.db.getStockQuantity(
|
||||
itemMap.Pen.name,
|
||||
locationMap.LocationOne,
|
||||
undefined,
|
||||
undefined,
|
||||
batchNumber
|
||||
),
|
||||
0
|
||||
);
|
||||
t.equal(
|
||||
await fyo.db.getStockQuantity(
|
||||
itemMap.Pen.name,
|
||||
locationMap.LocationTwo,
|
||||
undefined,
|
||||
undefined,
|
||||
batchNumber
|
||||
),
|
||||
quantity
|
||||
);
|
||||
});
|
||||
|
||||
test('create stock movement, material issue', async (t) => {
|
||||
const { rate } = itemMap.Ink;
|
||||
const quantity = 2;
|
||||
@ -169,6 +310,50 @@ test('create stock movement, material issue', async (t) => {
|
||||
t.equal(await fyo.db.getStockQuantity(itemMap.Ink.name), 0);
|
||||
});
|
||||
|
||||
test('Batch Enabled create stock movement, material issue', async (t) => {
|
||||
const { rate } = itemMap.Pen;
|
||||
const quantity = 2;
|
||||
const batchNumber = batchNumberMap.batchNumberOne.name;
|
||||
|
||||
const stockMovement = await getStockMovement(
|
||||
MovementType.MaterialIssue,
|
||||
new Date('2022-11-03T09:59:04.528'),
|
||||
[
|
||||
{
|
||||
item: itemMap.Pen.name,
|
||||
from: locationMap.LocationTwo,
|
||||
batchNumber,
|
||||
quantity,
|
||||
rate,
|
||||
},
|
||||
],
|
||||
fyo
|
||||
);
|
||||
|
||||
await (await stockMovement.sync()).submit();
|
||||
const name = stockMovement.name!;
|
||||
|
||||
const sles = await getSLEs(name, ModelNameEnum.StockMovement, fyo);
|
||||
t.equal(sles.length, 1);
|
||||
|
||||
const sle = sles[0];
|
||||
t.notEqual(new Date(sle.date).toString(), 'Invalid Date');
|
||||
t.equal(sle.item, itemMap.Pen.name);
|
||||
t.equal(parseFloat(sle.rate), rate);
|
||||
t.equal(sle.quantity, -quantity);
|
||||
t.equal(sle.location, locationMap.LocationTwo);
|
||||
t.equal(
|
||||
await fyo.db.getStockQuantity(
|
||||
itemMap.Pen.name,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
batchNumber
|
||||
),
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Section 3: Test Cancellation of Stock Movements
|
||||
*/
|
||||
@ -214,6 +399,7 @@ async function runEntries(
|
||||
item: string;
|
||||
to?: string;
|
||||
from?: string;
|
||||
batchNumber?: string;
|
||||
quantity: number;
|
||||
rate: number;
|
||||
}[];
|
||||
@ -235,7 +421,7 @@ async function runEntries(
|
||||
}
|
||||
|
||||
test('create stock movements, invalid entries, in sequence', async (t) => {
|
||||
const { name: item, rate } = itemMap.Ink;
|
||||
const { name: item, rate } = itemMap.Pen;
|
||||
const quantity = 10;
|
||||
await runEntries(
|
||||
item,
|
||||
@ -249,6 +435,7 @@ test('create stock movements, invalid entries, in sequence', async (t) => {
|
||||
{
|
||||
item,
|
||||
to: locationMap.LocationOne,
|
||||
batchNumber: batchNumberMap.batchNumberOne.name,
|
||||
quantity,
|
||||
rate,
|
||||
},
|
||||
@ -264,6 +451,7 @@ test('create stock movements, invalid entries, in sequence', async (t) => {
|
||||
item,
|
||||
from: locationMap.LocationOne,
|
||||
to: locationMap.LocationTwo,
|
||||
batchNumber: batchNumberMap.batchNumberOne.name,
|
||||
quantity: quantity + 1,
|
||||
rate,
|
||||
},
|
||||
@ -278,6 +466,7 @@ test('create stock movements, invalid entries, in sequence', async (t) => {
|
||||
{
|
||||
item,
|
||||
from: locationMap.LocationOne,
|
||||
batchNumber: batchNumberMap.batchNumberOne.name,
|
||||
quantity: quantity + 1,
|
||||
rate,
|
||||
},
|
||||
@ -292,6 +481,7 @@ test('create stock movements, invalid entries, in sequence', async (t) => {
|
||||
{
|
||||
item,
|
||||
from: locationMap.LocationOne,
|
||||
batchNumber: batchNumberMap.batchNumberOne.name,
|
||||
to: locationMap.LocationTwo,
|
||||
quantity,
|
||||
rate,
|
||||
@ -307,6 +497,7 @@ test('create stock movements, invalid entries, in sequence', async (t) => {
|
||||
{
|
||||
item,
|
||||
from: locationMap.LocationTwo,
|
||||
batchNumber: batchNumberMap.batchNumberOne.name,
|
||||
quantity,
|
||||
rate,
|
||||
},
|
||||
@ -371,4 +562,28 @@ test('create stock movements, invalid entries, out of sequence', async (t) => {
|
||||
);
|
||||
});
|
||||
|
||||
test('create stock movements, material issue, insufficient quantity', async (t) => {
|
||||
const { name, rate } = itemMap.Pen;
|
||||
const quantity = 2;
|
||||
const batchNumber = batchNumberMap.batchNumberOne.name;
|
||||
|
||||
const stockMovement = await getStockMovement(
|
||||
MovementType.MaterialIssue,
|
||||
new Date('2022-11-03T09:59:04.528'),
|
||||
[
|
||||
{
|
||||
item: itemMap.Pen.name,
|
||||
from: locationMap.LocationTwo,
|
||||
batchNumber,
|
||||
quantity,
|
||||
rate,
|
||||
},
|
||||
],
|
||||
fyo
|
||||
);
|
||||
|
||||
await assertThrows(async () => (await stockMovement.sync()).submit());
|
||||
t.equal(await fyo.db.getStockQuantity(name), 0);
|
||||
});
|
||||
|
||||
closeTestFyo(fyo, __filename);
|
||||
|
@ -22,6 +22,7 @@ export interface SMTransferDetails {
|
||||
item: string;
|
||||
rate: Money;
|
||||
quantity: number;
|
||||
batchNumber?: string;
|
||||
fromLocation?: string;
|
||||
toLocation?: string;
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ export enum ModelNameEnum {
|
||||
AccountingLedgerEntry = 'AccountingLedgerEntry',
|
||||
AccountingSettings = 'AccountingSettings',
|
||||
Address = 'Address',
|
||||
BatchNumber= 'BatchNumber',
|
||||
Color = 'Color',
|
||||
CompanySettings = 'CompanySettings',
|
||||
Currency = 'Currency',
|
||||
|
@ -260,6 +260,11 @@ export class StockLedger extends Report {
|
||||
label: 'Location',
|
||||
fieldtype: 'Link',
|
||||
},
|
||||
{
|
||||
fieldname: 'batchNumber',
|
||||
label: 'Batch No.',
|
||||
fieldtype: 'Link',
|
||||
},
|
||||
{
|
||||
fieldname: 'quantity',
|
||||
label: 'Quantity',
|
||||
|
@ -17,6 +17,7 @@ export async function getRawStockLedgerEntries(fyo: Fyo) {
|
||||
'name',
|
||||
'date',
|
||||
'item',
|
||||
'batchNumber',
|
||||
'rate',
|
||||
'quantity',
|
||||
'location',
|
||||
@ -42,7 +43,14 @@ export function getStockLedgerEntries(
|
||||
const name = safeParseInt(sle.name);
|
||||
const date = new Date(sle.date);
|
||||
const rate = safeParseFloat(sle.rate);
|
||||
const { item, location, quantity, referenceName, referenceType } = sle;
|
||||
const {
|
||||
item,
|
||||
location,
|
||||
batchNumber,
|
||||
quantity,
|
||||
referenceName,
|
||||
referenceType,
|
||||
} = sle;
|
||||
|
||||
if (quantity === 0) {
|
||||
continue;
|
||||
@ -80,6 +88,7 @@ export function getStockLedgerEntries(
|
||||
|
||||
item,
|
||||
location,
|
||||
batchNumber,
|
||||
|
||||
quantity,
|
||||
balanceQuantity,
|
||||
@ -124,7 +133,11 @@ export function getStockBalanceEntries(
|
||||
}
|
||||
|
||||
sbeMap[sle.item] ??= {};
|
||||
sbeMap[sle.item][sle.location] ??= getSBE(sle.item, sle.location);
|
||||
sbeMap[sle.item][sle.location] ??= getSBE(
|
||||
sle.item,
|
||||
sle.location,
|
||||
sle.batchNumber
|
||||
);
|
||||
const date = sle.date.valueOf();
|
||||
|
||||
if (fromDate && date < fromDate) {
|
||||
@ -146,12 +159,17 @@ export function getStockBalanceEntries(
|
||||
.flat();
|
||||
}
|
||||
|
||||
function getSBE(item: string, location: string): StockBalanceEntry {
|
||||
function getSBE(
|
||||
item: string,
|
||||
location: string,
|
||||
batchNumber: string
|
||||
): StockBalanceEntry {
|
||||
return {
|
||||
name: 0,
|
||||
|
||||
item,
|
||||
location,
|
||||
batchNumber,
|
||||
|
||||
balanceQuantity: 0,
|
||||
balanceValue: 0,
|
||||
|
@ -5,6 +5,7 @@ export interface RawStockLedgerEntry {
|
||||
date: string;
|
||||
item: string;
|
||||
rate: string;
|
||||
batchNumber: string;
|
||||
quantity: number;
|
||||
location: string;
|
||||
referenceName: string;
|
||||
@ -19,6 +20,7 @@ export interface ComputedStockLedgerEntry{
|
||||
|
||||
item: string;
|
||||
location:string;
|
||||
batchNumber: string;
|
||||
|
||||
quantity: number;
|
||||
balanceQuantity: number;
|
||||
@ -39,6 +41,7 @@ export interface StockBalanceEntry{
|
||||
|
||||
item: string;
|
||||
location:string;
|
||||
batchNumber: string;
|
||||
|
||||
balanceQuantity: number;
|
||||
balanceValue: number;
|
||||
|
27
schemas/app/BatchNumber.json
Normal file
27
schemas/app/BatchNumber.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "BatchNumber",
|
||||
"label": "Batch Number",
|
||||
"naming": "manual",
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Batch Number",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"fieldname": "expiryDate",
|
||||
"fieldtype": "Date",
|
||||
"label": "Expiry Date",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"fieldname": "manufactureDate",
|
||||
"fieldtype": "Date",
|
||||
"label": "Manufacture Date",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"quickEditFields": ["expiryDate", "manufactureDate"],
|
||||
"keywordFields": ["name"]
|
||||
}
|
@ -47,6 +47,14 @@
|
||||
"placeholder": "Unit",
|
||||
"readOnly": true
|
||||
},
|
||||
{
|
||||
"fieldname": "batchNumber",
|
||||
"label": "Batch No",
|
||||
"fieldtype": "Link",
|
||||
"create": true,
|
||||
"target": "BatchNumber",
|
||||
"placeholder": "Batch No"
|
||||
},
|
||||
{
|
||||
"fieldname": "quantity",
|
||||
"label": "Quantity",
|
||||
@ -126,7 +134,7 @@
|
||||
"readOnly": true
|
||||
}
|
||||
],
|
||||
"tableFields": ["item", "tax", "quantity", "rate", "amount"],
|
||||
"tableFields": ["item", "tax", "batchNumber", "quantity", "rate", "amount"],
|
||||
"keywordFields": ["item", "tax"],
|
||||
"quickEditFields": [
|
||||
"item",
|
||||
@ -138,6 +146,7 @@
|
||||
|
||||
"transferQuantity",
|
||||
"transferUnit",
|
||||
"batchNumber",
|
||||
"quantity",
|
||||
"unit",
|
||||
"unitConversionFactor",
|
||||
|
@ -131,6 +131,13 @@
|
||||
"section": "Inventory",
|
||||
"default": false
|
||||
},
|
||||
{
|
||||
"fieldname": "hasBatchNumber",
|
||||
"label": "Has Batch No",
|
||||
"fieldtype": "Check",
|
||||
"default": false,
|
||||
"section": "Inventory"
|
||||
},
|
||||
{
|
||||
"fieldname": "uomConversions",
|
||||
"label": "UOM Conversions",
|
||||
@ -151,6 +158,7 @@
|
||||
"barcode",
|
||||
"hsnCode",
|
||||
"trackItem",
|
||||
"hasBatchNumber",
|
||||
"uom"
|
||||
],
|
||||
"keywordFields": ["name", "itemType", "for"]
|
||||
|
@ -29,6 +29,11 @@
|
||||
"label": "Display Tax Invoice",
|
||||
"fieldtype": "Check"
|
||||
},
|
||||
{
|
||||
"fieldname": "displayBatchNumber",
|
||||
"label": "Display Batch Number",
|
||||
"fieldtype": "Check"
|
||||
},
|
||||
{
|
||||
"fieldname": "phone",
|
||||
"label": "Phone",
|
||||
@ -138,6 +143,7 @@
|
||||
"logo",
|
||||
"displayLogo",
|
||||
"displayTaxInvoice",
|
||||
"displayBatchNumber",
|
||||
"template",
|
||||
"color",
|
||||
"font",
|
||||
|
@ -49,6 +49,13 @@
|
||||
"label": "Ref. Type",
|
||||
"fieldtype": "Data",
|
||||
"readOnly": true
|
||||
},
|
||||
{
|
||||
"fieldname": "batchNumber",
|
||||
"label": "Batch No",
|
||||
"fieldtype": "Link",
|
||||
"target": "BatchNumber",
|
||||
"readOnly": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -50,6 +50,13 @@
|
||||
"placeholder": "Unit",
|
||||
"readOnly": true
|
||||
},
|
||||
{
|
||||
"fieldname": "batchNumber",
|
||||
"label": "Batch No",
|
||||
"fieldtype": "Link",
|
||||
"target": "BatchNumber",
|
||||
"create": true
|
||||
},
|
||||
{
|
||||
"fieldname": "quantity",
|
||||
"label": "Quantity",
|
||||
@ -77,7 +84,7 @@
|
||||
"readOnly": true
|
||||
}
|
||||
],
|
||||
"tableFields": ["item", "fromLocation", "toLocation", "quantity", "rate"],
|
||||
"tableFields": ["item", "fromLocation", "toLocation", "batchNumber", "quantity", "rate"],
|
||||
"keywordFields": ["item"],
|
||||
"quickEditFields": [
|
||||
"item",
|
||||
@ -86,6 +93,7 @@
|
||||
|
||||
"transferQuantity",
|
||||
"transferUnit",
|
||||
"batchNumber",
|
||||
"quantity",
|
||||
"unit",
|
||||
"unitConversionFactor",
|
||||
|
@ -41,6 +41,12 @@
|
||||
"placeholder": "Unit",
|
||||
"readOnly": true
|
||||
},
|
||||
{
|
||||
"fieldname": "batchNumber",
|
||||
"label": "Batch No",
|
||||
"fieldtype": "Link",
|
||||
"target": "BatchNumber"
|
||||
},
|
||||
{
|
||||
"fieldname": "quantity",
|
||||
"label": "Quantity",
|
||||
@ -80,12 +86,13 @@
|
||||
"placeholder": "HSN/SAC Code"
|
||||
}
|
||||
],
|
||||
"tableFields": ["item", "location", "quantity", "rate", "amount"],
|
||||
"tableFields": ["item", "location", "batchNumber", "quantity", "rate", "amount"],
|
||||
"quickEditFields": [
|
||||
"item",
|
||||
|
||||
"transferQuantity",
|
||||
"transferUnit",
|
||||
"batchNumber",
|
||||
"quantity",
|
||||
"unit",
|
||||
"unitConversionFactor",
|
||||
|
@ -47,6 +47,7 @@ import submittable from './meta/submittable.json';
|
||||
import tree from './meta/tree.json';
|
||||
import { Schema, SchemaStub } from './types';
|
||||
import InventorySettings from './app/inventory/InventorySettings.json';
|
||||
import BatchNumber from './app/BatchNumber.json'
|
||||
|
||||
export const coreSchemas: Schema[] = [
|
||||
PatchRun as Schema,
|
||||
@ -114,4 +115,6 @@ export const appSchemas: Schema[] | SchemaStub[] = [
|
||||
ShipmentItem as Schema,
|
||||
PurchaseReceipt as Schema,
|
||||
PurchaseReceiptItem as Schema,
|
||||
|
||||
BatchNumber as Schema
|
||||
];
|
||||
|
@ -83,6 +83,7 @@ export default {
|
||||
showHSN: this.showHSN,
|
||||
displayLogo: this.printSettings.displayLogo,
|
||||
displayTaxInvoice: this.printSettings.displayTaxInvoice,
|
||||
displayBatchNumber: this.printSettings.displayBatchNumber,
|
||||
discountAfterTax: this.doc.discountAfterTax,
|
||||
logo: this.printSettings.logo,
|
||||
companyName: this.fyo.singles.AccountingSettings.companyName,
|
||||
|
@ -71,7 +71,13 @@
|
||||
<div class="py-4 text-end w-2/12" v-if="printObject.showHSN">
|
||||
HSN/SAC
|
||||
</div>
|
||||
<div class="py-4 text-end w-1/12">Quantity</div>
|
||||
<div class="py-4 text-end w-2/12">Quantity</div>
|
||||
<div
|
||||
class="w-3/12 text-end py-4"
|
||||
v-if="printObject.displayBatchNumber"
|
||||
>
|
||||
Batch No
|
||||
</div>
|
||||
<div class="py-4 text-end w-3/12">Rate</div>
|
||||
<div class="py-4 text-end w-3/12">Amount</div>
|
||||
</div>
|
||||
@ -84,7 +90,13 @@
|
||||
<div class="w-2/12 text-end py-4" v-if="printObject.showHSN">
|
||||
{{ row.hsnCode }}
|
||||
</div>
|
||||
<div class="w-1/12 text-end py-4">{{ row.quantity }}</div>
|
||||
<div class="w-2/12 text-end py-4">{{ row.quantity }}</div>
|
||||
<div
|
||||
class="w-3/12 text-end py-4"
|
||||
v-if="printObject.displayBatchNumber"
|
||||
>
|
||||
{{ row.batchNumber }}
|
||||
</div>
|
||||
<div class="w-3/12 text-end py-4">{{ row.rate }}</div>
|
||||
<div class="w-3/12 text-end py-4">{{ row.amount }}</div>
|
||||
</div>
|
||||
|
@ -68,6 +68,9 @@
|
||||
<div class="w-4/12">Item</div>
|
||||
<div class="w-2/12 text-end" v-if="printObject.showHSN">HSN/SAC</div>
|
||||
<div class="w-2/12 text-end">Quantity</div>
|
||||
<div class="w-3/12 text-end" v-if="printObject.displayBatchNumber">
|
||||
Batch No
|
||||
</div>
|
||||
<div class="w-3/12 text-end">Rate</div>
|
||||
<div class="w-3/12 text-end">Amount</div>
|
||||
</div>
|
||||
@ -81,6 +84,9 @@
|
||||
{{ row.hsnCode }}
|
||||
</div>
|
||||
<div class="w-2/12 text-end">{{ row.quantity }}</div>
|
||||
<div class="w-3/12 text-end" v-if="printObject.displayBatchNumber">
|
||||
{{ row.batchNumber }}
|
||||
</div>
|
||||
<div class="w-3/12 text-end">{{ row.rate }}</div>
|
||||
<div class="w-3/12 text-end">{{ row.amount }}</div>
|
||||
</div>
|
||||
|
@ -112,6 +112,9 @@
|
||||
<div class="w-4/12">Item</div>
|
||||
<div class="w-2/12 text-end" v-if="printObject.showHSN">HSN/SAC</div>
|
||||
<div class="w-2/12 text-end">Quantity</div>
|
||||
<div class="w-3/12 text-end" v-if="printObject.displayBatchNumber">
|
||||
Batch No
|
||||
</div>
|
||||
<div class="w-3/12 text-end">Rate</div>
|
||||
<div class="w-3/12 text-end">Amount</div>
|
||||
</div>
|
||||
@ -125,6 +128,9 @@
|
||||
{{ row.hsnCode }}
|
||||
</div>
|
||||
<div class="w-2/12 text-end">{{ row.quantity }}</div>
|
||||
<div class="w-3/12 text-end" v-if="printObject.displayBatchNumber">
|
||||
{{ row.batchNumber }}
|
||||
</div>
|
||||
<div class="w-3/12 text-end">{{ row.rate }}</div>
|
||||
<div class="w-3/12 text-end">{{ row.amount }}</div>
|
||||
</div>
|
||||
|
@ -95,7 +95,7 @@ async function getInventorySidebar(): Promise<SidebarRoot[]> {
|
||||
label: t`Stock Balance`,
|
||||
name: 'stock-balance',
|
||||
route: '/report/StockBalance',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
];
|
||||
|
Loading…
Reference in New Issue
Block a user