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

Merge pull request #562 from frappe/fix-batchwise-inventory

fix: issues with Batch-wise Inventory
This commit is contained in:
Alan 2023-02-27 22:43:44 -08:00 committed by GitHub
commit ee495bb174
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 617 additions and 409 deletions

View File

@ -138,7 +138,7 @@ export class BespokeQueries {
location?: string, location?: string,
fromDate?: string, fromDate?: string,
toDate?: string, toDate?: string,
batchNumber?: string batch?: string
): Promise<number | null> { ): Promise<number | null> {
const query = db.knex!(ModelNameEnum.StockLedgerEntry) const query = db.knex!(ModelNameEnum.StockLedgerEntry)
.sum('quantity') .sum('quantity')
@ -148,8 +148,8 @@ export class BespokeQueries {
query.andWhere('location', location); query.andWhere('location', location);
} }
if (batchNumber) { if (batch) {
query.andWhere('batchNumber', batchNumber); query.andWhere('batch', batch);
} }
if (fromDate) { if (fromDate) {

View File

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

View File

@ -11,6 +11,7 @@ import {
import { DEFAULT_CURRENCY } from 'fyo/utils/consts'; import { DEFAULT_CURRENCY } from 'fyo/utils/consts';
import { ValidationError } from 'fyo/utils/errors'; import { ValidationError } from 'fyo/utils/errors';
import { addItem, getExchangeRate, getNumberSeries } from 'models/helpers'; import { addItem, getExchangeRate, getNumberSeries } from 'models/helpers';
import { validateBatch } from 'models/inventory/helpers';
import { InventorySettings } from 'models/inventory/InventorySettings'; import { InventorySettings } from 'models/inventory/InventorySettings';
import { StockTransfer } from 'models/inventory/StockTransfer'; import { StockTransfer } from 'models/inventory/StockTransfer';
import { Transactional } from 'models/Transactional/Transactional'; import { Transactional } from 'models/Transactional/Transactional';
@ -545,6 +546,7 @@ export abstract class Invoice extends Transactional {
const item = row.item; const item = row.item;
const quantity = row.stockNotTransferred; const quantity = row.stockNotTransferred;
const trackItem = itemDoc.trackItem; const trackItem = itemDoc.trackItem;
const batch = row.batch || null;
let rate = row.rate as Money; let rate = row.rate as Money;
if (this.exchangeRate && this.exchangeRate > 1) { if (this.exchangeRate && this.exchangeRate > 1) {
@ -560,6 +562,7 @@ export abstract class Invoice extends Transactional {
quantity, quantity,
location, location,
rate, rate,
batch,
}); });
} }

View File

@ -29,6 +29,7 @@ export abstract class InvoiceItem extends Doc {
quantity?: number; quantity?: number;
transferQuantity?: number; transferQuantity?: number;
unitConversionFactor?: number; unitConversionFactor?: number;
batch?: string;
tax?: string; tax?: string;
stockNotTransferred?: number; stockNotTransferred?: number;
@ -430,9 +431,13 @@ export abstract class InvoiceItem extends Doc {
!(this.enableDiscounting && !!this.setItemDiscountAmount), !(this.enableDiscounting && !!this.setItemDiscountAmount),
itemDiscountPercent: () => itemDiscountPercent: () =>
!(this.enableDiscounting && !this.setItemDiscountAmount), !(this.enableDiscounting && !this.setItemDiscountAmount),
transferUnit: () => !this.enableInventory, batch: () => !this.fyo.singles.InventorySettings?.enableBatches,
transferQuantity: () => !this.enableInventory, transferUnit: () =>
unitConversionFactor: () => !this.enableInventory, !this.fyo.singles.InventorySettings?.enableUomConversions,
transferQuantity: () =>
!this.fyo.singles.InventorySettings?.enableUomConversions,
unitConversionFactor: () =>
!this.fyo.singles.InventorySettings?.enableUomConversions,
}; };
static filters: FiltersMap = { static filters: FiltersMap = {

View File

@ -19,7 +19,7 @@ export class Item extends Doc {
trackItem?: boolean; trackItem?: boolean;
itemType?: 'Product' | 'Service'; itemType?: 'Product' | 'Service';
for?: 'Purchases' | 'Sales' | 'Both'; for?: 'Purchases' | 'Sales' | 'Both';
hasBatchNumber?: boolean; hasBatch?: boolean;
formulas: FormulaMap = { formulas: FormulaMap = {
incomeAccount: { incomeAccount: {
@ -76,29 +76,6 @@ export class Item extends Doc {
throw new ValidationError(this.fyo.t`Rate can't be negative.`); 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[] { static getActions(fyo: Fyo): Action[] {
@ -146,15 +123,17 @@ export class Item extends Doc {
!this.fyo.singles.AccountingSettings?.enableInventory || !this.fyo.singles.AccountingSettings?.enableInventory ||
this.itemType !== 'Product' || this.itemType !== 'Product' ||
(this.inserted && !this.trackItem), (this.inserted && !this.trackItem),
hasBatchNumber: () => !this.trackItem,
barcode: () => !this.fyo.singles.InventorySettings?.enableBarcodes, barcode: () => !this.fyo.singles.InventorySettings?.enableBarcodes,
uomConversions: () => !this.fyo.singles.AccountingSettings?.enableInventory, hasBatch: () =>
!(this.fyo.singles.InventorySettings?.enableBatches && this.trackItem),
uomConversions: () =>
!this.fyo.singles.InventorySettings?.enableUomConversions,
}; };
readOnly: ReadOnlyMap = { readOnly: ReadOnlyMap = {
unit: () => this.inserted, unit: () => this.inserted,
itemType: () => this.inserted, itemType: () => this.inserted,
trackItem: () => this.inserted, trackItem: () => this.inserted,
hasBatchNumber: () => this.inserted, hasBatch: () => this.inserted,
}; };
} }

View File

@ -26,14 +26,14 @@ import { ShipmentItem } from './inventory/ShipmentItem';
import { StockLedgerEntry } from './inventory/StockLedgerEntry'; import { StockLedgerEntry } from './inventory/StockLedgerEntry';
import { StockMovement } from './inventory/StockMovement'; import { StockMovement } from './inventory/StockMovement';
import { StockMovementItem } from './inventory/StockMovementItem'; import { StockMovementItem } from './inventory/StockMovementItem';
import { BatchNumber } from './baseModels/BatchNumber/BatchNumber'; import { Batch } from './inventory/Batch';
export const models = { export const models = {
Account, Account,
AccountingLedgerEntry, AccountingLedgerEntry,
AccountingSettings, AccountingSettings,
Address, Address,
BatchNumber, Batch,
Defaults, Defaults,
Item, Item,
JournalEntry, JournalEntry,

View File

@ -3,7 +3,7 @@ import {
ListViewSettings, ListViewSettings,
} from 'fyo/model/types'; } from 'fyo/model/types';
export class BatchNumber extends Doc { export class Batch extends Doc {
static getListViewSettings(): ListViewSettings { static getListViewSettings(): ListViewSettings {
return { return {
columns: ["name", "expiryDate", "manufactureDate"], columns: ["name", "expiryDate", "manufactureDate"],

View File

@ -1,5 +1,5 @@
import { Doc } from 'fyo/model/doc'; import { Doc } from 'fyo/model/doc';
import { FiltersMap } from 'fyo/model/types'; import { FiltersMap, ReadOnlyMap } from 'fyo/model/types';
import { AccountTypeEnum } from 'models/baseModels/Account/types'; import { AccountTypeEnum } from 'models/baseModels/Account/types';
import { ValuationMethod } from './types'; import { ValuationMethod } from './types';
@ -10,6 +10,8 @@ export class InventorySettings extends Doc {
stockReceivedButNotBilled?: string; stockReceivedButNotBilled?: string;
costOfGoodsSold?: string; costOfGoodsSold?: string;
enableBarcodes?: boolean; enableBarcodes?: boolean;
enableBatches?: boolean;
enableUomConversions?: boolean;
static filters: FiltersMap = { static filters: FiltersMap = {
stockInHand: () => ({ stockInHand: () => ({
@ -25,4 +27,16 @@ export class InventorySettings extends Doc {
accountType: AccountTypeEnum['Cost of Goods Sold'], accountType: AccountTypeEnum['Cost of Goods Sold'],
}), }),
}; };
readOnly: ReadOnlyMap = {
enableBarcodes: () => {
return !!this.enableBarcodes;
},
enableBatches: () => {
return !!this.enableBatches;
},
enableUomConversions: () => {
return !!this.enableUomConversions;
},
};
} }

View File

@ -10,5 +10,5 @@ export class StockLedgerEntry extends Doc {
location?: string; location?: string;
referenceName?: string; referenceName?: string;
referenceType?: string; referenceType?: string;
batchNumber?: string; batch?: string;
} }

View File

@ -1,6 +1,5 @@
import { Fyo, t } from 'fyo'; import { Fyo, t } from 'fyo';
import { ValidationError } from 'fyo/utils/errors'; import { ValidationError } from 'fyo/utils/errors';
import { DateTime } from 'luxon';
import { ModelNameEnum } from 'models/types'; import { ModelNameEnum } from 'models/types';
import { Money } from 'pesa'; import { Money } from 'pesa';
import { StockLedgerEntry } from './StockLedgerEntry'; import { StockLedgerEntry } from './StockLedgerEntry';
@ -133,43 +132,7 @@ 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 isItemHasBatchNumber = await this.fyo.getValue( const batch = details.batch || undefined;
'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 = let quantityBefore =
(await this.fyo.db.getStockQuantity( (await this.fyo.db.getStockQuantity(
@ -177,22 +140,24 @@ export class StockManager {
details.fromLocation, details.fromLocation,
undefined, undefined,
date, date,
undefined batch
)) ?? 0; )) ?? 0;
if (this.isCancelled) { if (this.isCancelled) {
quantityBefore += details.quantity; quantityBefore += details.quantity;
} }
const batchMessage = !!batch ? t` in Batch ${batch}` : '';
if (quantityBefore < details.quantity) { if (quantityBefore < details.quantity) {
throw new ValidationError( throw new ValidationError(
[ [
t`Insufficient Quantity.`, t`Insufficient Quantity.`,
t`Additional quantity (${ t`Additional quantity (${
details.quantity - quantityBefore details.quantity - quantityBefore
}) required to make outward transfer of item ${details.item} from ${ }) required${batchMessage} to make outward transfer of item ${
details.fromLocation details.item
} on ${formattedDate}`, } from ${details.fromLocation} on ${formattedDate}`,
].join('\n') ].join('\n')
); );
} }
@ -202,8 +167,9 @@ export class StockManager {
details.fromLocation, details.fromLocation,
details.date.toISOString(), details.date.toISOString(),
undefined, undefined,
undefined batch
); );
if (quantityAfter === null) { if (quantityAfter === null) {
// No future transactions // No future transactions
return; return;
@ -217,9 +183,9 @@ export class StockManager {
t`Transfer will cause future entries to have negative stock.`, t`Transfer will cause future entries to have negative stock.`,
t`Additional quantity (${ t`Additional quantity (${
quantityAfter - quantityRemaining quantityAfter - quantityRemaining
}) required to make outward transfer of item ${details.item} from ${ }) required${batchMessage} to make outward transfer of item ${
details.fromLocation details.item
} on ${formattedDate}`, } from ${details.fromLocation} on ${formattedDate}`,
].join('\n') ].join('\n')
); );
} }
@ -244,7 +210,7 @@ class StockManagerItem {
referenceType: string; referenceType: string;
fromLocation?: string; fromLocation?: string;
toLocation?: string; toLocation?: string;
batchNumber?: string; batch?: string;
stockLedgerEntries?: StockLedgerEntry[]; stockLedgerEntries?: StockLedgerEntry[];
@ -259,7 +225,7 @@ class StockManagerItem {
this.toLocation = details.toLocation; this.toLocation = details.toLocation;
this.referenceName = details.referenceName; this.referenceName = details.referenceName;
this.referenceType = details.referenceType; this.referenceType = details.referenceType;
this.batchNumber = details.batchNumber; this.batch = details.batch;
this.fyo = fyo; this.fyo = fyo;
} }
@ -312,7 +278,7 @@ class StockManagerItem {
date: this.date, date: this.date,
item: this.item, item: this.item,
rate: this.rate, rate: this.rate,
batchNumber: this.batchNumber, batch: this.batch || null,
quantity, quantity,
location, location,
referenceName: this.referenceName, referenceName: this.referenceName,

View File

@ -15,6 +15,7 @@ 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 { StockMovementItem } from './StockMovementItem'; import { StockMovementItem } from './StockMovementItem';
import { Transfer } from './Transfer'; import { Transfer } from './Transfer';
import { MovementType } from './types'; import { MovementType } from './types';
@ -49,6 +50,11 @@ export class StockMovement extends Transfer {
async validate() { async validate() {
await super.validate(); await super.validate();
this.validateManufacture();
await validateBatch(this);
}
validateManufacture() {
if (this.movementType !== MovementType.Manufacture) { if (this.movementType !== MovementType.Manufacture) {
return; return;
} }
@ -109,7 +115,7 @@ export class StockMovement extends Transfer {
item: row.item!, item: row.item!,
rate: row.rate!, rate: row.rate!,
quantity: row.quantity!, quantity: row.quantity!,
batchNumber: row.batchNumber!, batch: row.batch!,
fromLocation: row.fromLocation, fromLocation: row.fromLocation,
toLocation: row.toLocation, toLocation: row.toLocation,
})); }));

View File

@ -4,6 +4,7 @@ import { Doc } from 'fyo/model/doc';
import { import {
FiltersMap, FiltersMap,
FormulaMap, FormulaMap,
HiddenMap,
ReadOnlyMap, ReadOnlyMap,
RequiredMap, RequiredMap,
ValidationMap, ValidationMap,
@ -30,7 +31,7 @@ export class StockMovementItem extends Doc {
rate?: Money; rate?: Money;
amount?: Money; amount?: Money;
parentdoc?: StockMovement; parentdoc?: StockMovement;
batchNumber?: string; batch?: string;
get isIssue() { get isIssue() {
return this.parentdoc?.movementType === MovementType.MaterialIssue; return this.parentdoc?.movementType === MovementType.MaterialIssue;
@ -233,6 +234,16 @@ export class StockMovementItem extends Doc {
toLocation: () => this.isIssue, toLocation: () => this.isIssue,
}; };
override hidden: HiddenMap = {
batch: () => !this.fyo.singles.InventorySettings?.enableBatches,
transferUnit: () =>
!this.fyo.singles.InventorySettings?.enableUomConversions,
transferQuantity: () =>
!this.fyo.singles.InventorySettings?.enableUomConversions,
unitConversionFactor: () =>
!this.fyo.singles.InventorySettings?.enableUomConversions,
};
static createFilters: FiltersMap = { static createFilters: FiltersMap = {
item: () => ({ trackItem: true, itemType: 'Product' }), item: () => ({ trackItem: true, itemType: 'Product' }),
}; };

View File

@ -15,6 +15,7 @@ import { addItem, getLedgerLinkAction, getNumberSeries } from 'models/helpers';
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 { StockTransferItem } from './StockTransferItem'; import { StockTransferItem } from './StockTransferItem';
import { Transfer } from './Transfer'; import { Transfer } from './Transfer';
@ -82,7 +83,7 @@ export abstract class StockTransfer extends Transfer {
item: row.item!, item: row.item!,
rate: row.rate!, rate: row.rate!,
quantity: row.quantity!, quantity: row.quantity!,
batchNumber: row.batchNumber!, batch: row.batch!,
fromLocation, fromLocation,
toLocation, toLocation,
}; };
@ -151,6 +152,11 @@ export abstract class StockTransfer extends Transfer {
} }
} }
override async validate(): Promise<void> {
await super.validate();
await validateBatch(this);
}
static getActions(fyo: Fyo): Action[] { static getActions(fyo: Fyo): Action[] {
return [getLedgerLinkAction(fyo, false), getLedgerLinkAction(fyo, true)]; return [getLedgerLinkAction(fyo, false), getLedgerLinkAction(fyo, true)];
} }

View File

@ -1,7 +1,12 @@
import { t } from 'fyo'; import { t } from 'fyo';
import { DocValue } from 'fyo/core/types'; import { DocValue } from 'fyo/core/types';
import { Doc } from 'fyo/model/doc'; import { Doc } from 'fyo/model/doc';
import { FiltersMap, FormulaMap, ValidationMap } from 'fyo/model/types'; import {
FiltersMap,
FormulaMap,
HiddenMap,
ValidationMap,
} from 'fyo/model/types';
import { ValidationError } from 'fyo/utils/errors'; import { ValidationError } from 'fyo/utils/errors';
import { ModelNameEnum } from 'models/types'; import { ModelNameEnum } from 'models/types';
import { Money } from 'pesa'; import { Money } from 'pesa';
@ -21,7 +26,7 @@ export class StockTransferItem extends Doc {
amount?: Money; amount?: Money;
description?: string; description?: string;
hsnCode?: number; hsnCode?: number;
batchNumber?: string batch?: string;
formulas: FormulaMap = { formulas: FormulaMap = {
description: { description: {
@ -200,4 +205,14 @@ export class StockTransferItem extends Doc {
return { for: ['not in', [itemNotFor]], trackItem: true }; return { for: ['not in', [itemNotFor]], trackItem: true };
}, },
}; };
override hidden: HiddenMap = {
batch: () => !this.fyo.singles.InventorySettings?.enableBatches,
transferUnit: () =>
!this.fyo.singles.InventorySettings?.enableUomConversions,
transferQuantity: () =>
!this.fyo.singles.InventorySettings?.enableUomConversions,
unitConversionFactor: () =>
!this.fyo.singles.InventorySettings?.enableUomConversions,
};
} }

View File

@ -1 +1,51 @@
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 { StockMovement } from './StockMovement';
import { StockMovementItem } from './StockMovementItem';
import { StockTransfer } from './StockTransfer';
import { StockTransferItem } from './StockTransferItem';
export async function validateBatch(
doc: StockMovement | StockTransfer | Invoice
) {
for (const row of doc.items ?? []) {
await validateItemRowBatch(row);
}
}
async function validateItemRowBatch(
doc: StockMovementItem | StockTransferItem | InvoiceItem
) {
const idx = doc.idx ?? 0 + 1;
const item = doc.item;
const batch = doc.batch;
if (!item) {
return;
}
const hasBatch = await doc.fyo.getValue(
ModelNameEnum.Item,
item,
'hasBatch'
);
if (!hasBatch && batch) {
throw new ValidationError(
[
doc.fyo.t`Batch set for row ${idx}.`,
doc.fyo.t`Item ${item} is not a batched item`,
].join(' ')
);
}
if (hasBatch && !batch) {
throw new ValidationError(
[
doc.fyo.t`Batch not set for row ${idx}.`,
doc.fyo.t`Item ${item} is a batched item`,
].join(' ')
);
}
}

View File

@ -1,5 +1,5 @@
import { Fyo } from 'fyo'; import { Fyo } from 'fyo';
import { BatchNumber } from 'models/baseModels/BatchNumber/BatchNumber'; import { Batch } from 'models/inventory/Batch';
import { ModelNameEnum } from 'models/types'; import { ModelNameEnum } from 'models/types';
import { StockMovement } from '../StockMovement'; import { StockMovement } from '../StockMovement';
import { StockTransfer } from '../StockTransfer'; import { StockTransfer } from '../StockTransfer';
@ -27,7 +27,7 @@ type Transfer = {
item: string; item: string;
from?: string; from?: string;
to?: string; to?: string;
batchNumber?: string; batch?: string;
quantity: number; quantity: number;
rate: number; rate: number;
}; };
@ -36,22 +36,22 @@ interface TransferTwo extends Omit<Transfer, 'from' | 'to'> {
location: string; location: string;
} }
export function getItem(name: string, rate: number, hasBatchNumber?: boolean) { export function getItem(name: string, rate: number, hasBatch: boolean = false) {
return { name, rate, trackItem: true, hasBatchNumber }; return { name, rate, trackItem: true, hasBatch };
} }
export async function getBatchNumber( export async function getBatch(
schemaName: ModelNameEnum.BatchNumber, schemaName: ModelNameEnum.Batch,
batchNumber: string, batch: string,
expiryDate: Date, expiryDate: Date,
manufactureDate: Date, manufactureDate: Date,
fyo: Fyo fyo: Fyo
): Promise<BatchNumber> { ): Promise<Batch> {
const doc = fyo.doc.getNewDoc(schemaName, { const doc = fyo.doc.getNewDoc(schemaName, {
batchNumber, batch,
expiryDate, expiryDate,
manufactureDate, manufactureDate,
}) as BatchNumber; }) as Batch;
return doc; return doc;
} }
@ -83,7 +83,7 @@ export async function getStockMovement(
item, item,
from: fromLocation, from: fromLocation,
to: toLocation, to: toLocation,
batchNumber: batchNumber, batch,
quantity, quantity,
rate, rate,
} of transfers) { } of transfers) {
@ -91,7 +91,7 @@ export async function getStockMovement(
item, item,
fromLocation, fromLocation,
toLocation, toLocation,
batchNumber, batch,
rate, rate,
quantity, quantity,
}); });

View File

@ -0,0 +1,288 @@
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, getSLEs, 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 batchMap = {
batchOne: {
name: 'PN-AB001',
manufactureDate: '2022-11-03T09:57:04.528',
},
batchTwo: {
name: 'PN-AB002',
manufactureDate: '2022-10-03T09:57:04.528',
},
batchThree: {
name: 'PN-AB003',
manufactureDate: '2022-10-03T09:57:04.528',
},
};
test('create dummy items, locations & batches', async (t) => {
// Create Items
for (const { name, rate } of Object.values(itemMap)) {
const item = getItem(name, rate, 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 Batches
for (const batch of Object.values(batchMap)) {
const doc = fyo.doc.getNewDoc(ModelNameEnum.Batch, batch);
await doc.sync();
const exists = await fyo.db.exists(ModelNameEnum.Batch, batch.name);
t.ok(exists, `${batch.name} exists`);
}
});
test('batched item, create stock movement, material receipt', async (t) => {
const { rate } = itemMap.Pen;
const stockMovement = await getStockMovement(
MovementType.MaterialReceipt,
new Date('2022-11-03T09:57:04.528'),
[
{
item: itemMap.Pen.name,
to: locationMap.LocationOne,
quantity: 2,
batch: batchMap.batchOne.name,
rate,
},
{
item: itemMap.Pen.name,
to: locationMap.LocationOne,
quantity: 1,
batch: batchMap.batchTwo.name,
rate,
},
],
fyo
);
await (await stockMovement.sync()).submit();
t.equal(
await fyo.db.getStockQuantity(
itemMap.Pen.name,
locationMap.LocationOne,
undefined,
undefined,
batchMap.batchOne.name
),
2,
'batch one has quantity two'
);
t.equal(
await fyo.db.getStockQuantity(
itemMap.Pen.name,
locationMap.LocationOne,
undefined,
undefined,
batchMap.batchTwo.name
),
1,
'batch two has quantity one'
);
t.equal(
await fyo.db.getStockQuantity(
itemMap.Pen.name,
locationMap.LocationOne,
undefined,
undefined,
batchMap.batchThree.name
),
null,
'batch three has no quantity'
);
t.equal(
await fyo.db.getStockQuantity(
itemMap.Ink.name,
locationMap.LocationOne,
undefined,
undefined,
batchMap.batchOne.name
),
null,
'non transacted item has no quantity'
);
});
test('batched item, create stock movement, material issue', async (t) => {
const { rate } = itemMap.Pen;
const quantity = 2;
const batch = batchMap.batchOne.name;
const stockMovement = await getStockMovement(
MovementType.MaterialIssue,
new Date('2022-11-03T10:00:00.528'),
[
{
item: itemMap.Pen.name,
from: locationMap.LocationOne,
batch,
quantity,
rate,
},
],
fyo
);
await (await stockMovement.sync()).submit();
t.equal(
await fyo.db.getStockQuantity(
itemMap.Pen.name,
locationMap.LocationOne,
undefined,
undefined,
batch
),
0,
'batch one quantity transacted out'
);
t.equal(
await fyo.db.getStockQuantity(
itemMap.Pen.name,
locationMap.LocationOne,
undefined,
undefined,
batchMap.batchTwo.name
),
1,
'batch two quantity intact'
);
});
test('batched item, create stock movement, material transfer', async (t) => {
const { rate } = itemMap.Pen;
const quantity = 1;
const batch = batchMap.batchTwo.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,
batch,
quantity,
rate,
},
],
fyo
);
await (await stockMovement.sync()).submit();
t.equal(
await fyo.db.getStockQuantity(
itemMap.Pen.name,
locationMap.LocationOne,
undefined,
undefined,
batch
),
0,
'location one batch transacted out'
);
t.equal(
await fyo.db.getStockQuantity(
itemMap.Pen.name,
locationMap.LocationTwo,
undefined,
undefined,
batch
),
quantity,
'location two batch transacted in'
);
});
test('batched 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,
batchMap.batchTwo.name
);
t.equal(quantity, 1, 'location two, batch 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,
batch: batchMap.batchOne.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 batch did not throw'
);
t.equal(await fyo.db.getStockQuantity(name), 1, 'item still has quantity');
});
closeTestFyo(fyo, __filename);

View File

@ -17,12 +17,10 @@ const itemMap = {
Pen: { Pen: {
name: 'Pen', name: 'Pen',
rate: 700, rate: 700,
hasBatchNumber: true,
}, },
Ink: { Ink: {
name: 'Ink', name: 'Ink',
rate: 50, rate: 50,
hasBatchNumber: false,
}, },
}; };
@ -31,22 +29,14 @@ const locationMap = {
LocationTwo: 'LocationTwo', 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 * Section 1: Test Creation of Items and Locations
*/ */
test('create dummy items & locations', async (t) => { test('create dummy items & locations', async (t) => {
// Create Items // Create Items
for (const { name, rate, hasBatchNumber } of Object.values(itemMap)) { for (const { name, rate } of Object.values(itemMap)) {
const item = getItem(name, rate, hasBatchNumber); const item = getItem(name, rate);
await fyo.doc.getNewDoc(ModelNameEnum.Item, item).sync(); await fyo.doc.getNewDoc(ModelNameEnum.Item, item).sync();
t.ok(await fyo.db.exists(ModelNameEnum.Item, name), `${name} exists`); t.ok(await fyo.db.exists(ModelNameEnum.Item, name), `${name} exists`);
} }
@ -58,25 +48,6 @@ 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 * Section 2: Test Creation of Stock Movements
*/ */
@ -119,55 +90,6 @@ test('create stock movement, material receipt', async (t) => {
t.equal(await fyo.db.getStockQuantity(itemMap.Ink.name), quantity); 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) => { test('create stock movement, material transfer', async (t) => {
const { rate } = itemMap.Ink; const { rate } = itemMap.Ink;
const quantity = 2; const quantity = 2;
@ -214,69 +136,6 @@ test('create stock movement, material transfer', async (t) => {
t.equal(await fyo.db.getStockQuantity(itemMap.Ink.name), quantity); 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) => { test('create stock movement, material issue', async (t) => {
const { rate } = itemMap.Ink; const { rate } = itemMap.Ink;
const quantity = 2; const quantity = 2;
@ -310,50 +169,6 @@ test('create stock movement, material issue', async (t) => {
t.equal(await fyo.db.getStockQuantity(itemMap.Ink.name), 0); 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 * Section 3: Test Cancellation of Stock Movements
*/ */
@ -399,7 +214,6 @@ async function runEntries(
item: string; item: string;
to?: string; to?: string;
from?: string; from?: string;
batchNumber?: string;
quantity: number; quantity: number;
rate: number; rate: number;
}[]; }[];
@ -435,7 +249,6 @@ test('create stock movements, invalid entries, in sequence', async (t) => {
{ {
item, item,
to: locationMap.LocationOne, to: locationMap.LocationOne,
batchNumber: batchNumberMap.batchNumberOne.name,
quantity, quantity,
rate, rate,
}, },
@ -451,7 +264,6 @@ test('create stock movements, invalid entries, in sequence', async (t) => {
item, item,
from: locationMap.LocationOne, from: locationMap.LocationOne,
to: locationMap.LocationTwo, to: locationMap.LocationTwo,
batchNumber: batchNumberMap.batchNumberOne.name,
quantity: quantity + 1, quantity: quantity + 1,
rate, rate,
}, },
@ -466,7 +278,6 @@ test('create stock movements, invalid entries, in sequence', async (t) => {
{ {
item, item,
from: locationMap.LocationOne, from: locationMap.LocationOne,
batchNumber: batchNumberMap.batchNumberOne.name,
quantity: quantity + 1, quantity: quantity + 1,
rate, rate,
}, },
@ -481,7 +292,6 @@ test('create stock movements, invalid entries, in sequence', async (t) => {
{ {
item, item,
from: locationMap.LocationOne, from: locationMap.LocationOne,
batchNumber: batchNumberMap.batchNumberOne.name,
to: locationMap.LocationTwo, to: locationMap.LocationTwo,
quantity, quantity,
rate, rate,
@ -497,7 +307,6 @@ test('create stock movements, invalid entries, in sequence', async (t) => {
{ {
item, item,
from: locationMap.LocationTwo, from: locationMap.LocationTwo,
batchNumber: batchNumberMap.batchNumberOne.name,
quantity, quantity,
rate, rate,
}, },
@ -562,28 +371,4 @@ 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); closeTestFyo(fyo, __filename);

View File

@ -22,7 +22,7 @@ export interface SMTransferDetails {
item: string; item: string;
rate: Money; rate: Money;
quantity: number; quantity: number;
batchNumber?: string; batch?: string;
fromLocation?: string; fromLocation?: string;
toLocation?: string; toLocation?: string;
} }

View File

@ -4,7 +4,7 @@ export enum ModelNameEnum {
AccountingLedgerEntry = 'AccountingLedgerEntry', AccountingLedgerEntry = 'AccountingLedgerEntry',
AccountingSettings = 'AccountingSettings', AccountingSettings = 'AccountingSettings',
Address = 'Address', Address = 'Address',
BatchNumber= 'BatchNumber', Batch= 'Batch',
Color = 'Color', Color = 'Color',
CompanySettings = 'CompanySettings', CompanySettings = 'CompanySettings',
Currency = 'Currency', Currency = 'Currency',

View File

@ -25,9 +25,11 @@ export class StockBalance extends StockLedger {
const filters = { const filters = {
item: this.item, item: this.item,
location: this.location, location: this.location,
batch: this.batch,
fromDate: this.fromDate, fromDate: this.fromDate,
toDate: this.toDate, toDate: this.toDate,
}; };
const rawData = getStockBalanceEntries(this._rawData ?? [], filters); const rawData = getStockBalanceEntries(this._rawData ?? [], filters);
return rawData.map((sbe, i) => { return rawData.map((sbe, i) => {
@ -41,7 +43,7 @@ export class StockBalance extends StockLedger {
} }
getFilters(): Field[] { getFilters(): Field[] {
return [ const filters = [
{ {
fieldtype: 'Link', fieldtype: 'Link',
target: 'Item', target: 'Item',
@ -56,6 +58,17 @@ export class StockBalance extends StockLedger {
label: t`Location`, label: t`Location`,
fieldname: 'location', fieldname: 'location',
}, },
...(this.hasBatches
? [
{
fieldtype: 'Link',
target: 'Batch',
placeholder: t`Batch`,
label: t`Batch`,
fieldname: 'batch',
},
]
: []),
{ {
fieldtype: 'Date', fieldtype: 'Date',
placeholder: t`From Date`, placeholder: t`From Date`,
@ -69,6 +82,8 @@ export class StockBalance extends StockLedger {
fieldname: 'toDate', fieldname: 'toDate',
}, },
] as Field[]; ] as Field[];
return filters;
} }
getColumns(): ColumnField[] { getColumns(): ColumnField[] {
@ -89,6 +104,11 @@ export class StockBalance extends StockLedger {
label: 'Location', label: 'Location',
fieldtype: 'Link', fieldtype: 'Link',
}, },
...(this.hasBatches
? ([
{ fieldname: 'batch', label: 'Batch', fieldtype: 'Link' },
] as ColumnField[])
: []),
{ {
fieldname: 'balanceQuantity', fieldname: 'balanceQuantity',
label: 'Balance Qty.', label: 'Balance Qty.',

View File

@ -3,6 +3,7 @@ import { RawValueMap } from 'fyo/core/types';
import { Action } from 'fyo/model/types'; import { Action } from 'fyo/model/types';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { InventorySettings } from 'models/inventory/InventorySettings';
import { ValuationMethod } from 'models/inventory/types'; import { ValuationMethod } from 'models/inventory/types';
import { ModelNameEnum } from 'models/types'; import { ModelNameEnum } from 'models/types';
import getCommonExportActions from 'reports/commonExporter'; import getCommonExportActions from 'reports/commonExporter';
@ -26,6 +27,7 @@ export class StockLedger extends Report {
item?: string; item?: string;
location?: string; location?: string;
batch?: string;
fromDate?: string; fromDate?: string;
toDate?: string; toDate?: string;
ascending?: boolean; ascending?: boolean;
@ -34,6 +36,11 @@ export class StockLedger extends Report {
groupBy: 'none' | 'item' | 'location' = 'none'; groupBy: 'none' | 'item' | 'location' = 'none';
get hasBatches(): boolean {
return !!(this.fyo.singles.InventorySettings as InventorySettings)
.enableBatches;
}
constructor(fyo: Fyo) { constructor(fyo: Fyo) {
super(fyo); super(fyo);
this._setObservers(); this._setObservers();
@ -110,6 +117,10 @@ export class StockLedger extends Report {
continue; continue;
} }
if (this.batch && row.batch !== this.batch) {
continue;
}
const date = row.date.valueOf(); const date = row.date.valueOf();
if (toDate && date > toDate) { if (toDate && date > toDate) {
continue; continue;
@ -260,11 +271,11 @@ export class StockLedger extends Report {
label: 'Location', label: 'Location',
fieldtype: 'Link', fieldtype: 'Link',
}, },
{ ...(this.hasBatches
fieldname: 'batchNumber', ? ([
label: 'Batch No.', { fieldname: 'batch', label: 'Batch', fieldtype: 'Link' },
fieldtype: 'Link', ] as ColumnField[])
}, : []),
{ {
fieldname: 'quantity', fieldname: 'quantity',
label: 'Quantity', label: 'Quantity',
@ -344,6 +355,17 @@ export class StockLedger extends Report {
label: t`Location`, label: t`Location`,
fieldname: 'location', fieldname: 'location',
}, },
...(this.hasBatches
? ([
{
fieldtype: 'Link',
target: 'Batch',
placeholder: t`Batch`,
label: t`Batch`,
fieldname: 'batch',
},
] as Field[])
: []),
{ {
fieldtype: 'Date', fieldtype: 'Date',
placeholder: t`From Date`, placeholder: t`From Date`,

View File

@ -11,13 +11,14 @@ import {
type Item = string; type Item = string;
type Location = string; type Location = string;
type Batch = string;
export async function getRawStockLedgerEntries(fyo: Fyo) { export async function getRawStockLedgerEntries(fyo: Fyo) {
const fieldnames = [ const fieldnames = [
'name', 'name',
'date', 'date',
'item', 'item',
'batchNumber', 'batch',
'rate', 'rate',
'quantity', 'quantity',
'location', 'location',
@ -37,29 +38,27 @@ export function getStockLedgerEntries(
valuationMethod: ValuationMethod valuationMethod: ValuationMethod
): ComputedStockLedgerEntry[] { ): ComputedStockLedgerEntry[] {
const computedSLEs: ComputedStockLedgerEntry[] = []; const computedSLEs: ComputedStockLedgerEntry[] = [];
const stockQueues: Record<Item, Record<Location, StockQueue>> = {}; const stockQueues: Record<
Item,
Record<Location, Record<Batch, StockQueue>>
> = {};
for (const sle of rawSLEs) { for (const sle of rawSLEs) {
const name = safeParseInt(sle.name); const name = safeParseInt(sle.name);
const date = new Date(sle.date); const date = new Date(sle.date);
const rate = safeParseFloat(sle.rate); const rate = safeParseFloat(sle.rate);
const { const { item, location, quantity, referenceName, referenceType } = sle;
item, const batch = sle.batch ?? '';
location,
batchNumber,
quantity,
referenceName,
referenceType,
} = sle;
if (quantity === 0) { if (quantity === 0) {
continue; continue;
} }
stockQueues[item] ??= {}; stockQueues[item] ??= {};
stockQueues[item][location] ??= new StockQueue(); stockQueues[item][location] ??= {};
stockQueues[item][location][batch] ??= new StockQueue();
const q = stockQueues[item][location]; const q = stockQueues[item][location][batch];
const initialValue = q.value; const initialValue = q.value;
let incomingRate: number | null; let incomingRate: number | null;
@ -88,7 +87,7 @@ export function getStockLedgerEntries(
item, item,
location, location,
batchNumber, batch,
quantity, quantity,
balanceQuantity, balanceQuantity,
@ -116,9 +115,13 @@ export function getStockBalanceEntries(
location?: string; location?: string;
fromDate?: string; fromDate?: string;
toDate?: string; toDate?: string;
batch?: string;
} }
): StockBalanceEntry[] { ): StockBalanceEntry[] {
const sbeMap: Record<Item, Record<Location, StockBalanceEntry>> = {}; const sbeMap: Record<
Item,
Record<Location, Record<Batch, StockBalanceEntry>>
> = {};
const fromDate = filters.fromDate ? Date.parse(filters.fromDate) : null; const fromDate = filters.fromDate ? Date.parse(filters.fromDate) : null;
const toDate = filters.toDate ? Date.parse(filters.toDate) : null; const toDate = filters.toDate ? Date.parse(filters.toDate) : null;
@ -132,16 +135,23 @@ export function getStockBalanceEntries(
continue; continue;
} }
if (filters.batch && sle.batch !== filters.batch) {
continue;
}
const batch = sle.batch || '';
sbeMap[sle.item] ??= {}; sbeMap[sle.item] ??= {};
sbeMap[sle.item][sle.location] ??= getSBE( sbeMap[sle.item][sle.location] ??= {};
sbeMap[sle.item][sle.location][batch] ??= getSBE(
sle.item, sle.item,
sle.location, sle.location,
sle.batchNumber batch
); );
const date = sle.date.valueOf(); const date = sle.date.valueOf();
if (fromDate && date < fromDate) { if (fromDate && date < fromDate) {
const sbe = sbeMap[sle.item][sle.location]!; const sbe = sbeMap[sle.item][sle.location][batch];
updateOpeningBalances(sbe, sle); updateOpeningBalances(sbe, sle);
continue; continue;
} }
@ -150,26 +160,28 @@ export function getStockBalanceEntries(
continue; continue;
} }
const sbe = sbeMap[sle.item][sle.location]!; const sbe = sbeMap[sle.item][sle.location][batch];
updateCurrentBalances(sbe, sle); updateCurrentBalances(sbe, sle);
} }
return Object.values(sbeMap) return Object.values(sbeMap)
.map((sbes) => Object.values(sbes)) .map((sbeBatched) =>
.flat(); Object.values(sbeBatched).map((sbes) => Object.values(sbes))
)
.flat(2);
} }
function getSBE( function getSBE(
item: string, item: string,
location: string, location: string,
batchNumber: string batch: string
): StockBalanceEntry { ): StockBalanceEntry {
return { return {
name: 0, name: 0,
item, item,
location, location,
batchNumber, batch,
balanceQuantity: 0, balanceQuantity: 0,
balanceValue: 0, balanceValue: 0,

View File

@ -5,7 +5,7 @@ export interface RawStockLedgerEntry {
date: string; date: string;
item: string; item: string;
rate: string; rate: string;
batchNumber: string; batch: string | null;
quantity: number; quantity: number;
location: string; location: string;
referenceName: string; referenceName: string;
@ -20,7 +20,7 @@ export interface ComputedStockLedgerEntry{
item: string; item: string;
location:string; location:string;
batchNumber: string; batch: string;
quantity: number; quantity: number;
balanceQuantity: number; balanceQuantity: number;
@ -41,7 +41,7 @@ export interface StockBalanceEntry{
item: string; item: string;
location:string; location:string;
batchNumber: string; batch: string;
balanceQuantity: number; balanceQuantity: number;
balanceValue: number; balanceValue: number;

View File

@ -1,12 +1,12 @@
{ {
"name": "BatchNumber", "name": "Batch",
"label": "Batch Number", "label": "Batch",
"naming": "manual", "naming": "manual",
"fields": [ "fields": [
{ {
"fieldname": "name", "fieldname": "name",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Batch Number", "label": "Batch",
"required": true "required": true
}, },
{ {

View File

@ -48,12 +48,12 @@
"readOnly": true "readOnly": true
}, },
{ {
"fieldname": "batchNumber", "fieldname": "batch",
"label": "Batch No", "label": "Batch",
"fieldtype": "Link", "fieldtype": "Link",
"create": true, "create": true,
"target": "BatchNumber", "target": "Batch",
"placeholder": "Batch No" "placeholder": "Batch"
}, },
{ {
"fieldname": "quantity", "fieldname": "quantity",
@ -134,7 +134,7 @@
"readOnly": true "readOnly": true
} }
], ],
"tableFields": ["item", "tax", "batchNumber", "quantity", "rate", "amount"], "tableFields": ["item", "tax", "quantity", "rate", "amount"],
"keywordFields": ["item", "tax"], "keywordFields": ["item", "tax"],
"quickEditFields": [ "quickEditFields": [
"item", "item",
@ -146,7 +146,7 @@
"transferQuantity", "transferQuantity",
"transferUnit", "transferUnit",
"batchNumber", "batch",
"quantity", "quantity",
"unit", "unit",
"unitConversionFactor", "unitConversionFactor",

View File

@ -132,8 +132,8 @@
"default": false "default": false
}, },
{ {
"fieldname": "hasBatchNumber", "fieldname": "hasBatch",
"label": "Has Batch No", "label": "Has Batch",
"fieldtype": "Check", "fieldtype": "Check",
"default": false, "default": false,
"section": "Inventory" "section": "Inventory"
@ -158,7 +158,6 @@
"barcode", "barcode",
"hsnCode", "hsnCode",
"trackItem", "trackItem",
"hasBatchNumber",
"uom" "uom"
], ],
"keywordFields": ["name", "itemType", "for"] "keywordFields": ["name", "itemType", "for"]

View File

@ -30,8 +30,8 @@
"fieldtype": "Check" "fieldtype": "Check"
}, },
{ {
"fieldname": "displayBatchNumber", "fieldname": "displayBatch",
"label": "Display Batch Number", "label": "Display Batch",
"fieldtype": "Check" "fieldtype": "Check"
}, },
{ {
@ -143,7 +143,7 @@
"logo", "logo",
"displayLogo", "displayLogo",
"displayTaxInvoice", "displayTaxInvoice",
"displayBatchNumber", "displayBatch",
"template", "template",
"color", "color",
"font", "font",

View File

@ -50,6 +50,16 @@
"fieldname": "enableBarcodes", "fieldname": "enableBarcodes",
"label": "Enable Barcodes", "label": "Enable Barcodes",
"fieldtype": "Check" "fieldtype": "Check"
},
{
"fieldname": "enableBatches",
"label": "Enable Batches",
"fieldtype": "Check"
},
{
"fieldname": "enableUomConversions",
"label": "Enable UOM Conversion",
"fieldtype": "Check"
} }
] ]
} }

View File

@ -51,10 +51,10 @@
"readOnly": true "readOnly": true
}, },
{ {
"fieldname": "batchNumber", "fieldname": "batch",
"label": "Batch No", "label": "Batch",
"fieldtype": "Link", "fieldtype": "Link",
"target": "BatchNumber", "target": "Batch",
"readOnly": true "readOnly": true
} }
] ]

View File

@ -51,10 +51,10 @@
"readOnly": true "readOnly": true
}, },
{ {
"fieldname": "batchNumber", "fieldname": "batch",
"label": "Batch No", "label": "Batch",
"fieldtype": "Link", "fieldtype": "Link",
"target": "BatchNumber", "target": "Batch",
"create": true "create": true
}, },
{ {
@ -84,7 +84,7 @@
"readOnly": true "readOnly": true
} }
], ],
"tableFields": ["item", "fromLocation", "toLocation", "batchNumber", "quantity", "rate"], "tableFields": ["item", "fromLocation", "toLocation", "quantity", "rate"],
"keywordFields": ["item"], "keywordFields": ["item"],
"quickEditFields": [ "quickEditFields": [
"item", "item",
@ -93,7 +93,7 @@
"transferQuantity", "transferQuantity",
"transferUnit", "transferUnit",
"batchNumber", "batch",
"quantity", "quantity",
"unit", "unit",
"unitConversionFactor", "unitConversionFactor",

View File

@ -42,10 +42,10 @@
"readOnly": true "readOnly": true
}, },
{ {
"fieldname": "batchNumber", "fieldname": "batch",
"label": "Batch No", "label": "Batch",
"fieldtype": "Link", "fieldtype": "Link",
"target": "BatchNumber" "target": "Batch"
}, },
{ {
"fieldname": "quantity", "fieldname": "quantity",
@ -86,13 +86,13 @@
"placeholder": "HSN/SAC Code" "placeholder": "HSN/SAC Code"
} }
], ],
"tableFields": ["item", "location", "batchNumber", "quantity", "rate", "amount"], "tableFields": ["item", "location", "quantity", "rate", "amount"],
"quickEditFields": [ "quickEditFields": [
"item", "item",
"transferQuantity", "transferQuantity",
"transferUnit", "transferUnit",
"batchNumber", "batch",
"quantity", "quantity",
"unit", "unit",
"unitConversionFactor", "unitConversionFactor",

View File

@ -47,7 +47,7 @@ import submittable from './meta/submittable.json';
import tree from './meta/tree.json'; import tree from './meta/tree.json';
import { Schema, SchemaStub } from './types'; import { Schema, SchemaStub } from './types';
import InventorySettings from './app/inventory/InventorySettings.json'; import InventorySettings from './app/inventory/InventorySettings.json';
import BatchNumber from './app/BatchNumber.json' import Batch from './app/Batch.json'
export const coreSchemas: Schema[] = [ export const coreSchemas: Schema[] = [
PatchRun as Schema, PatchRun as Schema,
@ -116,5 +116,5 @@ export const appSchemas: Schema[] | SchemaStub[] = [
PurchaseReceipt as Schema, PurchaseReceipt as Schema,
PurchaseReceiptItem as Schema, PurchaseReceiptItem as Schema,
BatchNumber as Schema Batch as Schema
]; ];

View File

@ -83,7 +83,7 @@ export default {
showHSN: this.showHSN, showHSN: this.showHSN,
displayLogo: this.printSettings.displayLogo, displayLogo: this.printSettings.displayLogo,
displayTaxInvoice: this.printSettings.displayTaxInvoice, displayTaxInvoice: this.printSettings.displayTaxInvoice,
displayBatchNumber: this.printSettings.displayBatchNumber, displayBatch: this.printSettings.displayBatch,
discountAfterTax: this.doc.discountAfterTax, discountAfterTax: this.doc.discountAfterTax,
logo: this.printSettings.logo, logo: this.printSettings.logo,
companyName: this.fyo.singles.AccountingSettings.companyName, companyName: this.fyo.singles.AccountingSettings.companyName,

View File

@ -74,9 +74,9 @@
<div class="py-4 text-end w-2/12">Quantity</div> <div class="py-4 text-end w-2/12">Quantity</div>
<div <div
class="w-3/12 text-end py-4" class="w-3/12 text-end py-4"
v-if="printObject.displayBatchNumber" v-if="printObject.displayBatch"
> >
Batch No Batch
</div> </div>
<div class="py-4 text-end w-3/12">Rate</div> <div class="py-4 text-end w-3/12">Rate</div>
<div class="py-4 text-end w-3/12">Amount</div> <div class="py-4 text-end w-3/12">Amount</div>
@ -93,9 +93,9 @@
<div class="w-2/12 text-end py-4">{{ row.quantity }}</div> <div class="w-2/12 text-end py-4">{{ row.quantity }}</div>
<div <div
class="w-3/12 text-end py-4" class="w-3/12 text-end py-4"
v-if="printObject.displayBatchNumber" v-if="printObject.displayBatch"
> >
{{ row.batchNumber }} {{ row.batch }}
</div> </div>
<div class="w-3/12 text-end py-4">{{ row.rate }}</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 class="w-3/12 text-end py-4">{{ row.amount }}</div>

View File

@ -68,8 +68,8 @@
<div class="w-4/12">Item</div> <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" v-if="printObject.showHSN">HSN/SAC</div>
<div class="w-2/12 text-end">Quantity</div> <div class="w-2/12 text-end">Quantity</div>
<div class="w-3/12 text-end" v-if="printObject.displayBatchNumber"> <div class="w-3/12 text-end" v-if="printObject.displayBatch">
Batch No Batch
</div> </div>
<div class="w-3/12 text-end">Rate</div> <div class="w-3/12 text-end">Rate</div>
<div class="w-3/12 text-end">Amount</div> <div class="w-3/12 text-end">Amount</div>
@ -84,8 +84,8 @@
{{ row.hsnCode }} {{ row.hsnCode }}
</div> </div>
<div class="w-2/12 text-end">{{ row.quantity }}</div> <div class="w-2/12 text-end">{{ row.quantity }}</div>
<div class="w-3/12 text-end" v-if="printObject.displayBatchNumber"> <div class="w-3/12 text-end" v-if="printObject.displayBatch">
{{ row.batchNumber }} {{ row.batch }}
</div> </div>
<div class="w-3/12 text-end">{{ row.rate }}</div> <div class="w-3/12 text-end">{{ row.rate }}</div>
<div class="w-3/12 text-end">{{ row.amount }}</div> <div class="w-3/12 text-end">{{ row.amount }}</div>

View File

@ -112,8 +112,8 @@
<div class="w-4/12">Item</div> <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" v-if="printObject.showHSN">HSN/SAC</div>
<div class="w-2/12 text-end">Quantity</div> <div class="w-2/12 text-end">Quantity</div>
<div class="w-3/12 text-end" v-if="printObject.displayBatchNumber"> <div class="w-3/12 text-end" v-if="printObject.displayBatch">
Batch No Batch
</div> </div>
<div class="w-3/12 text-end">Rate</div> <div class="w-3/12 text-end">Rate</div>
<div class="w-3/12 text-end">Amount</div> <div class="w-3/12 text-end">Amount</div>
@ -128,8 +128,8 @@
{{ row.hsnCode }} {{ row.hsnCode }}
</div> </div>
<div class="w-2/12 text-end">{{ row.quantity }}</div> <div class="w-2/12 text-end">{{ row.quantity }}</div>
<div class="w-3/12 text-end" v-if="printObject.displayBatchNumber"> <div class="w-3/12 text-end" v-if="printObject.displayBatch">
{{ row.batchNumber }} {{ row.batch }}
</div> </div>
<div class="w-3/12 text-end">{{ row.rate }}</div> <div class="w-3/12 text-end">{{ row.rate }}</div>
<div class="w-3/12 text-end">{{ row.amount }}</div> <div class="w-3/12 text-end">{{ row.amount }}</div>

View File

@ -120,16 +120,26 @@ export default {
if (this.fieldsChanged.length === 0) { if (this.fieldsChanged.length === 0) {
return; return;
} }
const fieldnames = this.fieldsChanged.map(({ fieldname }) => fieldname);
if ( const shouleShowReload = this.fieldsChanged
fieldnames.includes('displayPrecision') || .map(({ fieldname }) => fieldname)
fieldnames.includes('hideGetStarted') || .some((f) => {
fieldnames.includes('displayPrecision') || if (f.startsWith('enable')) {
fieldnames.includes('enableDiscounting') || return true;
fieldnames.includes('enableInventory') || }
fieldnames.includes('enableBarcodes')
) { if (f === 'displayPrecision') {
return true;
}
if (f === 'hideGetStarted') {
return true;
}
return false;
});
if (shouleShowReload) {
this.showReloadToast(); this.showReloadToast();
} }
}, },

View File

@ -108,6 +108,7 @@ export const docsPathMap: Record<string, string | undefined> = {
[ModelNameEnum.PurchaseReceipt]: 'inventory/purchase-receipt', [ModelNameEnum.PurchaseReceipt]: 'inventory/purchase-receipt',
StockLedger: 'inventory/stock-ledger', StockLedger: 'inventory/stock-ledger',
StockBalance: 'inventory/stock-balance', StockBalance: 'inventory/stock-balance',
[ModelNameEnum.Batch]: 'inventory/batches',
// Entries // Entries
Entries: 'entries/entries', Entries: 'entries/entries',

View File

@ -230,10 +230,16 @@ function getListViewList(fyo: Fyo): SearchItem[] {
schemaNames.push( schemaNames.push(
ModelNameEnum.StockMovement, ModelNameEnum.StockMovement,
ModelNameEnum.Shipment, ModelNameEnum.Shipment,
ModelNameEnum.PurchaseReceipt ModelNameEnum.PurchaseReceipt,
ModelNameEnum.Location
); );
} }
const hasBatch = fyo.doc.singles.InventorySettings?.enableBatches;
if (hasBatch) {
schemaNames.push(ModelNameEnum.Batch);
}
if (fyo.store.isDevelopment) { if (fyo.store.isDevelopment) {
schemaNames = Object.keys(fyo.schemaMap) as ModelNameEnum[]; schemaNames = Object.keys(fyo.schemaMap) as ModelNameEnum[];
} }