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

incr: validate against negative stock

This commit is contained in:
18alantom 2022-11-03 16:23:34 +05:30
parent 43784984c3
commit 2bc0ce2237
7 changed files with 356 additions and 60 deletions

View File

@ -1,3 +1,4 @@
import { ModelNameEnum } from '../../models/types';
import DatabaseCore from './core';
import { BespokeFunction } from './types';
@ -130,4 +131,35 @@ export class BespokeQueries {
group by account
`);
}
static async getStockQuantity(
db: DatabaseCore,
item: string,
location?: string,
fromDate?: string,
toDate?: string
): Promise<number | null> {
const query = db.knex!(ModelNameEnum.StockLedgerEntry)
.sum('quantity')
.where('item', item);
if (location) {
query.andWhere('location', location);
}
if (fromDate) {
query.andWhereRaw('datetime(date) > datetime(?)', [fromDate]);
}
if (toDate) {
query.andWhereRaw('datetime(date) < datetime(?)', [toDate]);
}
const value = (await query) as Record<string, number | null>[];
if (!value.length) {
return null;
}
return value[0][Object.keys(value[0])[0]];
}
}

View File

@ -1,12 +1,12 @@
import assert from 'assert';
import { cloneDeep } from 'lodash';
import { SchemaMap, SchemaStub, SchemaStubMap } from 'schemas/types';
import {
addMetaFields,
cleanSchemas,
getAbstractCombinedSchemas,
} from '../../../schemas';
import SingleValue from '../../../schemas/core/SingleValue.json';
import { SchemaMap, SchemaStub, SchemaStubMap } from '../../../schemas/types';
const Customer = {
name: 'Customer',
@ -188,7 +188,7 @@ export async function assertThrows(
} finally {
if (!threw) {
throw new assert.AssertionError({
message: `Missing expected exception: ${message}`,
message: `Missing expected exception${message ? `: ${message}` : ''}`,
});
}
}
@ -202,9 +202,9 @@ export async function assertDoesNotThrow(
await func();
} catch (err) {
throw new assert.AssertionError({
message: `Got unwanted exception: ${message}\nError: ${
(err as Error).message
}\n${(err as Error).stack}`,
message: `Got unwanted exception${
message ? `: ${message}` : ''
}\nError: ${(err as Error).message}\n${(err as Error).stack}`,
});
}
}

View File

@ -19,7 +19,7 @@ import {
DatabaseDemuxConstructor,
DocValue,
DocValueMap,
RawValueMap
RawValueMap,
} from './types';
// Return types of Bespoke Queries
@ -306,6 +306,21 @@ export class DatabaseHandler extends DatabaseBase {
)) as TotalCreditAndDebit[];
}
async getStockQuantity(
item: string,
location?: string,
fromDate?: string,
toDate?: string
): Promise<number | null> {
return (await this.#demux.callBespoke(
'getStockQuantity',
item,
location,
fromDate,
toDate
)) as number | null;
}
/**
* Internal methods
*/

View File

@ -26,8 +26,13 @@ export class StockManager {
}
async createTransfers(transferDetails: SMTransferDetails[]) {
for (const detail of transferDetails) {
await this.#createTransfer(detail);
const detailsList = transferDetails.map((d) => this.#getSMIDetails(d));
for (const details of detailsList) {
await this.#validate(details);
}
for (const details of detailsList) {
await this.#createTransfer(details);
}
await this.#sync();
@ -47,16 +52,111 @@ export class StockManager {
}
}
async #createTransfer(transferDetails: SMTransferDetails) {
const details = this.#getSMIDetails(transferDetails);
async #createTransfer(details: SMIDetails) {
const item = new StockManagerItem(details, this.fyo);
await item.transferStock();
item.transferStock();
this.items.push(item);
}
#getSMIDetails(transferDetails: SMTransferDetails): SMIDetails {
return Object.assign({}, this.details, transferDetails);
}
async #validate(details: SMIDetails) {
this.#validateRate(details);
this.#validateQuantity(details);
this.#validateLocation(details);
await this.#validateStockAvailability(details);
}
#validateQuantity(details: SMIDetails) {
if (!details.quantity) {
throw new ValidationError(t`Quantity needs to be set`);
}
if (details.quantity <= 0) {
throw new ValidationError(
t`Quantity (${details.quantity}) has to be greater than zero`
);
}
}
#validateRate(details: SMIDetails) {
if (!details.rate) {
throw new ValidationError(t`Rate needs to be set`);
}
if (details.rate.lte(0)) {
throw new ValidationError(
t`Rate (${details.rate.float}) has to be greater than zero`
);
}
}
#validateLocation(details: SMIDetails) {
if (details.fromLocation) {
return;
}
if (details.toLocation) {
return;
}
throw new ValidationError(t`Both From and To Location cannot be undefined`);
}
async #validateStockAvailability(details: SMIDetails) {
if (!details.fromLocation) {
return;
}
const quantityBefore =
(await this.fyo.db.getStockQuantity(
details.item,
details.fromLocation,
undefined,
details.date.toISOString()
)) ?? 0;
const formattedDate = this.fyo.format(details.date, 'Datetime');
if (quantityBefore < details.quantity) {
throw new ValidationError(
[
t`Insufficient Quantity.`,
t`Additional quantity (${
details.quantity - quantityBefore
}) required to make outward transfer of item ${details.item} from ${
details.fromLocation
} on ${formattedDate}`,
].join('\n')
);
}
const quantityAfter = await this.fyo.db.getStockQuantity(
details.item,
details.fromLocation,
details.date.toISOString()
);
if (quantityAfter === null) {
// No future transactions
return;
}
const quantityRemaining = quantityBefore - details.quantity;
if (quantityAfter < quantityRemaining) {
throw new ValidationError(
[
t`Insufficient Quantity.`,
t`Transfer will cause future entries to have negative stock.`,
t`Additional quantity (${
quantityAfter - quantityRemaining
}) required to make outward transfer of item ${details.item} from ${
details.fromLocation
} on ${formattedDate}`,
].join('\n')
);
}
}
}
class StockManagerItem {
@ -91,7 +191,6 @@ class StockManagerItem {
this.toLocation = details.toLocation;
this.referenceName = details.referenceName;
this.referenceType = details.referenceType;
this.#validate();
this.fyo = fyo;
}
@ -102,8 +201,15 @@ class StockManagerItem {
}
async sync() {
for (const sle of this.stockLedgerEntries ?? []) {
await sle.sync();
const sles = [
this.stockLedgerEntries?.filter((s) => s.quantity! <= 0),
this.stockLedgerEntries?.filter((s) => s.quantity! > 0),
]
.flat()
.filter(Boolean);
for (const sle of sles) {
await sle!.sync();
}
}
@ -147,48 +253,4 @@ class StockManagerItem {
#clear() {
this.stockLedgerEntries = [];
}
#validate() {
this.#validateRate();
this.#validateQuantity();
this.#validateLocation();
}
#validateQuantity() {
if (!this.quantity) {
throw new ValidationError(t`Stock Manager: quantity needs to be set`);
}
if (this.quantity <= 0) {
throw new ValidationError(
t`Stock Manager: quantity (${this.quantity}) has to be greater than zero`
);
}
}
#validateRate() {
if (!this.rate) {
throw new ValidationError(t`Stock Manager: rate needs to be set`);
}
if (this.rate.lte(0)) {
throw new ValidationError(
t`Stock Manager: rate (${this.rate.float}) has to be greater than zero`
);
}
}
#validateLocation() {
if (this.fromLocation) {
return;
}
if (this.toLocation) {
return;
}
throw new ValidationError(
t`Stock Manager: both From and To Location cannot be undefined`
);
}
}

View File

@ -26,11 +26,13 @@ export function getItem(name: string, rate: number) {
export async function getStockMovement(
movementType: MovementType,
date: Date,
transfers: Transfer[],
fyo: Fyo
): Promise<StockMovement> {
const doc = fyo.doc.getNewDoc(ModelNameEnum.StockMovement, {
movementType,
date,
}) as StockMovement;
for (const {

View File

@ -1,5 +1,9 @@
import {
assertDoesNotThrow,
assertThrows
} from 'backend/database/tests/helpers';
import { ModelNameEnum } from 'models/types';
import test from 'tape';
import { default as tape, default as test } from 'tape';
import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers';
import { StockMovement } from '../StockMovement';
import { MovementType } from '../types';
@ -54,6 +58,7 @@ test('create stock movement, material receipt', async (t) => {
const amount = rate * quantity;
const stockMovement = await getStockMovement(
MovementType.MaterialReceipt,
new Date('2022-11-03T09:57:04.528'),
[
{
item: itemMap.Ink.name,
@ -82,6 +87,7 @@ test('create stock movement, material receipt', async (t) => {
t.equal(parseFloat(sle.rate), rate);
t.equal(sle.quantity, quantity);
t.equal(sle.location, locationMap.LocationOne);
t.equal(await fyo.db.getStockQuantity(itemMap.Ink.name), quantity);
});
test('create stock movement, material transfer', async (t) => {
@ -90,6 +96,7 @@ test('create stock movement, material transfer', async (t) => {
const stockMovement = await getStockMovement(
MovementType.MaterialTransfer,
new Date('2022-11-03T09:58:04.528'),
[
{
item: itemMap.Ink.name,
@ -121,6 +128,12 @@ test('create stock movement, material transfer', async (t) => {
t.ok(false, 'no-op');
}
}
t.equal(
await fyo.db.getStockQuantity(itemMap.Ink.name, locationMap.LocationOne),
0
);
t.equal(await fyo.db.getStockQuantity(itemMap.Ink.name), quantity);
});
test('create stock movement, material issue', async (t) => {
@ -129,6 +142,7 @@ test('create stock movement, material issue', async (t) => {
const stockMovement = await getStockMovement(
MovementType.MaterialIssue,
new Date('2022-11-03T09:59:04.528'),
[
{
item: itemMap.Ink.name,
@ -152,6 +166,7 @@ test('create stock movement, material issue', async (t) => {
t.equal(parseFloat(sle.rate), rate);
t.equal(sle.quantity, -quantity);
t.equal(sle.location, locationMap.LocationTwo);
t.equal(await fyo.db.getStockQuantity(itemMap.Ink.name), 0);
});
/**
@ -180,10 +195,180 @@ test('cancel stock movement', async (t) => {
const slesAfter = await getSLEs(name, ModelNameEnum.StockMovement, fyo);
t.equal(slesAfter.length, 0);
}
t.equal(await fyo.db.getStockQuantity(itemMap.Ink.name), null);
});
/**
* Section 4: Test Invalid entries
*/
async function runEntries(
item: string,
entries: {
type: MovementType;
date: Date;
valid: boolean;
postQuantity: number;
items: {
item: string;
to?: string;
from?: string;
quantity: number;
rate: number;
}[];
}[],
t: tape.Test
) {
for (const { type, date, items, valid, postQuantity } of entries) {
const stockMovement = await getStockMovement(type, date, items, fyo);
await stockMovement.sync();
if (valid) {
await assertDoesNotThrow(async () => await stockMovement.submit());
} else {
await assertThrows(async () => await stockMovement.submit());
}
t.equal(await fyo.db.getStockQuantity(item), postQuantity);
}
}
test('create stock movements, invalid entries, in sequence', async (t) => {
const { name: item, rate } = itemMap.Ink;
const quantity = 10;
await runEntries(
item,
[
{
type: MovementType.MaterialReceipt,
date: new Date('2022-11-03T09:58:04.528'),
valid: true,
postQuantity: quantity,
items: [
{
item,
to: locationMap.LocationOne,
quantity,
rate,
},
],
},
{
type: MovementType.MaterialTransfer,
date: new Date('2022-11-03T09:58:05.528'),
valid: false,
postQuantity: quantity,
items: [
{
item,
from: locationMap.LocationOne,
to: locationMap.LocationTwo,
quantity: quantity + 1,
rate,
},
],
},
{
type: MovementType.MaterialIssue,
date: new Date('2022-11-03T09:58:06.528'),
valid: false,
postQuantity: quantity,
items: [
{
item,
from: locationMap.LocationOne,
quantity: quantity + 1,
rate,
},
],
},
{
type: MovementType.MaterialTransfer,
date: new Date('2022-11-03T09:58:07.528'),
valid: true,
postQuantity: quantity,
items: [
{
item,
from: locationMap.LocationOne,
to: locationMap.LocationTwo,
quantity,
rate,
},
],
},
{
type: MovementType.MaterialIssue,
date: new Date('2022-11-03T09:58:08.528'),
valid: true,
postQuantity: 0,
items: [
{
item,
from: locationMap.LocationTwo,
quantity,
rate,
},
],
},
],
t
);
});
test('create stock movements, invalid entries, out of sequence', async (t) => {
const { name: item, rate } = itemMap.Ink;
const quantity = 10;
await runEntries(
item,
[
{
type: MovementType.MaterialReceipt,
date: new Date('2022-11-15'),
valid: true,
postQuantity: quantity,
items: [
{
item,
to: locationMap.LocationOne,
quantity,
rate,
},
],
},
{
type: MovementType.MaterialIssue,
date: new Date('2022-11-17'),
valid: true,
postQuantity: quantity - 5,
items: [
{
item,
from: locationMap.LocationOne,
quantity: quantity - 5,
rate,
},
],
},
{
type: MovementType.MaterialTransfer,
date: new Date('2022-11-16'),
valid: false,
postQuantity: quantity - 5,
items: [
{
item,
from: locationMap.LocationOne,
to: locationMap.LocationTwo,
quantity,
rate,
},
],
},
],
t
);
});
closeTestFyo(fyo, __filename);

View File

@ -50,7 +50,7 @@
},
{
"fieldname": "amount",
"label": "Amount",
"label": "Total Amount",
"fieldtype": "Currency",
"readOnly": true
},