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:
parent
43784984c3
commit
2bc0ce2237
@ -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]];
|
||||
}
|
||||
}
|
||||
|
@ -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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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);
|
||||
|
@ -50,7 +50,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "amount",
|
||||
"label": "Amount",
|
||||
"label": "Total Amount",
|
||||
"fieldtype": "Currency",
|
||||
"readOnly": true
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user