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

fix: stock movement

- test: stock movement create and cancel
This commit is contained in:
18alantom 2022-11-02 20:26:37 +05:30
parent 2a5686058d
commit 43784984c3
7 changed files with 327 additions and 82 deletions

View File

@ -46,6 +46,7 @@ export const databaseMethodSet: Set<DatabaseMethod> = new Set([
'rename', 'rename',
'update', 'update',
'delete', 'delete',
'deleteAll',
'close', 'close',
'exists', 'exists',
]); ]);

View File

@ -129,7 +129,7 @@ export class Doc extends Observable<DocValue | Doc[]> {
return !!this.submitted && !!this.cancelled; return !!this.submitted && !!this.cancelled;
} }
get syncing() { get isSyncing() {
return this._syncing; return this._syncing;
} }
@ -159,13 +159,18 @@ export class Doc extends Observable<DocValue | Doc[]> {
_setValuesWithoutChecks(data: DocValueMap, convertToDocValue: boolean) { _setValuesWithoutChecks(data: DocValueMap, convertToDocValue: boolean) {
for (const field of this.schema.fields) { for (const field of this.schema.fields) {
const fieldname = field.fieldname; const { fieldname, fieldtype } = field;
const value = data[field.fieldname]; const value = data[field.fieldname];
if (Array.isArray(value)) { if (Array.isArray(value)) {
for (const row of value) { for (const row of value) {
this.push(fieldname, row, convertToDocValue); this.push(fieldname, row, convertToDocValue);
} }
} else if (
fieldtype === FieldTypeEnum.Currency &&
typeof value === 'number'
) {
this[fieldname] = this.fyo.pesa(value);
} else if (value !== undefined && !convertToDocValue) { } else if (value !== undefined && !convertToDocValue) {
this[fieldname] = value; this[fieldname] = value;
} else if (value !== undefined) { } else if (value !== undefined) {
@ -269,13 +274,13 @@ export class Doc extends Observable<DocValue | Doc[]> {
} }
async _applyChange( async _applyChange(
fieldname: string, changedFieldname: string,
retriggerChildDocApplyChange?: boolean retriggerChildDocApplyChange?: boolean
): Promise<boolean> { ): Promise<boolean> {
await this._applyFormula(fieldname, retriggerChildDocApplyChange); await this._applyFormula(changedFieldname, retriggerChildDocApplyChange);
await this.trigger('change', { await this.trigger('change', {
doc: this, doc: this,
changed: fieldname, changed: changedFieldname,
}); });
return true; return true;
@ -666,16 +671,17 @@ export class Doc extends Observable<DocValue | Doc[]> {
} }
async _applyFormula( async _applyFormula(
fieldname?: string, changedFieldname?: string,
retriggerChildDocApplyChange?: boolean retriggerChildDocApplyChange?: boolean
): Promise<boolean> { ): Promise<boolean> {
const doc = this; const doc = this;
let changed = await this._callAllTableFieldsApplyFormula(fieldname); let changed = await this._callAllTableFieldsApplyFormula(changedFieldname);
changed = (await this._applyFormulaForFields(doc, fieldname)) || changed; changed =
(await this._applyFormulaForFields(doc, changedFieldname)) || changed;
if (changed && retriggerChildDocApplyChange) { if (changed && retriggerChildDocApplyChange) {
await this._callAllTableFieldsApplyFormula(fieldname); await this._callAllTableFieldsApplyFormula(changedFieldname);
await this._applyFormulaForFields(doc, fieldname); await this._applyFormulaForFields(doc, changedFieldname);
} }
return changed; return changed;

View File

@ -105,10 +105,45 @@ export function shouldApplyFormula(field: Field, doc: Doc, fieldname?: string) {
return true; return true;
} }
if (doc.isSyncing && dependsOn.length > 0) {
return shouldApplyFormulaPreSync(field.fieldname, dependsOn, doc);
}
const value = doc.get(field.fieldname); const value = doc.get(field.fieldname);
return getIsNullOrUndef(value); return getIsNullOrUndef(value);
} }
function shouldApplyFormulaPreSync(
fieldname: string,
dependsOn: string[],
doc: Doc
): boolean {
if (isDocValueTruthy(doc.get(fieldname))) {
return false;
}
for (const d of dependsOn) {
const isSet = isDocValueTruthy(doc.get(d));
if (isSet) {
return true;
}
}
return false;
}
export function isDocValueTruthy(docValue: DocValue | Doc[]) {
if (isPesa(docValue)) {
return !(docValue as Money).isZero();
}
if (Array.isArray(docValue)) {
return docValue.length > 0;
}
return !!docValue;
}
export function setChildDocIdx(childDocs: Doc[]) { export function setChildDocIdx(childDocs: Doc[]) {
for (const idx in childDocs) { for (const idx in childDocs) {
childDocs[idx].idx = +idx; childDocs[idx].idx = +idx;

View File

@ -25,25 +25,41 @@ export class StockManager {
this.fyo = fyo; this.fyo = fyo;
} }
async transferStock(transferDetails: SMTransferDetails) { async createTransfers(transferDetails: SMTransferDetails[]) {
const details = this.#getSMIDetails(transferDetails); for (const detail of transferDetails) {
const item = new StockManagerItem(details, this.fyo); await this.#createTransfer(detail);
await item.transferStock(this.isCancelled);
this.items.push(item);
} }
async sync() { await this.#sync();
}
async cancelTransfers() {
const { referenceName, referenceType } = this.details;
await this.fyo.db.deleteAll(ModelNameEnum.StockLedgerEntry, {
referenceType,
referenceName,
});
}
async #sync() {
for (const item of this.items) { for (const item of this.items) {
await item.sync(); await item.sync();
} }
} }
async #createTransfer(transferDetails: SMTransferDetails) {
const details = this.#getSMIDetails(transferDetails);
const item = new StockManagerItem(details, this.fyo);
await item.transferStock();
this.items.push(item);
}
#getSMIDetails(transferDetails: SMTransferDetails): SMIDetails { #getSMIDetails(transferDetails: SMTransferDetails): SMIDetails {
return Object.assign({}, this.details, transferDetails); return Object.assign({}, this.details, transferDetails);
} }
} }
export class StockManagerItem { class StockManagerItem {
/** /**
* The Stock Manager Item is used to move stock to and from a location. It * The Stock Manager Item is used to move stock to and from a location. It
* updates the Stock Queue and creates Stock Ledger Entries. * updates the Stock Queue and creates Stock Ledger Entries.
@ -80,9 +96,9 @@ export class StockManagerItem {
this.fyo = fyo; this.fyo = fyo;
} }
async transferStock(isCancelled: boolean) { transferStock() {
this.#clear(); this.#clear();
await this.#moveStockForBothLocations(isCancelled); this.#moveStockForBothLocations();
} }
async sync() { async sync() {
@ -91,29 +107,17 @@ export class StockManagerItem {
} }
} }
async #moveStockForBothLocations(isCancelled: boolean) { #moveStockForBothLocations() {
if (this.fromLocation) { if (this.fromLocation) {
await this.#moveStockForSingleLocation( this.#moveStockForSingleLocation(this.fromLocation, true);
this.fromLocation,
isCancelled ? false : true,
isCancelled
);
} }
if (this.toLocation) { if (this.toLocation) {
await this.#moveStockForSingleLocation( this.#moveStockForSingleLocation(this.toLocation, false);
this.toLocation,
isCancelled ? true : false,
isCancelled
);
} }
} }
async #moveStockForSingleLocation( #moveStockForSingleLocation(location: string, isOutward: boolean) {
location: string,
isOutward: boolean,
isCancelled: boolean
) {
let quantity = this.quantity!; let quantity = this.quantity!;
if (quantity === 0) { if (quantity === 0) {
return; return;
@ -124,11 +128,9 @@ export class StockManagerItem {
} }
// Stock Ledger Entry // Stock Ledger Entry
if (!isCancelled) {
const stockLedgerEntry = this.#getStockLedgerEntry(location, quantity); const stockLedgerEntry = this.#getStockLedgerEntry(location, quantity);
this.stockLedgerEntries?.push(stockLedgerEntry); this.stockLedgerEntries?.push(stockLedgerEntry);
} }
}
#getStockLedgerEntry(location: string, quantity: number) { #getStockLedgerEntry(location: string, quantity: number) {
return this.fyo.doc.getNewDoc(ModelNameEnum.StockLedgerEntry, { return this.fyo.doc.getNewDoc(ModelNameEnum.StockLedgerEntry, {

View File

@ -44,29 +44,22 @@ export class StockMovement extends Doc {
} }
async afterSubmit(): Promise<void> { async afterSubmit(): Promise<void> {
await this._transferStock(); const transferDetails = this._getTransferDetails();
await this._getStockManager().createTransfers(transferDetails);
} }
async afterCancel(): Promise<void> { async afterCancel(): Promise<void> {
await this._transferStock(); await this._getStockManager().cancelTransfers();
} }
async _transferStock() { _getTransferDetails() {
const stockManager = this._getStockManager(); return (this.items ?? []).map((row) => ({
this._makeTransfers(stockManager);
await stockManager.sync();
}
_makeTransfers(stockManager: StockManager) {
for (const row of this.items ?? []) {
stockManager.transferStock({
item: row.item!, item: row.item!,
rate: row.rate!, rate: row.rate!,
quantity: row.quantity!, quantity: row.quantity!,
fromLocation: row.fromLocation, fromLocation: row.fromLocation,
toLocation: row.toLocation, toLocation: row.toLocation,
}); }));
}
} }
_getStockManager(): StockManager { _getStockManager(): StockManager {

View File

@ -1,21 +1,64 @@
import { Fyo } from 'fyo'; import { Fyo } from 'fyo';
import { ModelNameEnum } from 'models/types'; import { ModelNameEnum } from 'models/types';
import test from 'tape'; import { StockMovement } from '../StockMovement';
import { MovementType } from '../types';
export const dummyItems = [ type SLE = {
{ date: string;
name: 'Ball Pen', name: string;
rate: 50, item: string;
for: 'Both', location: string;
trackItem: true, rate: string;
}, quantity: string;
{ };
name: 'Ink Pen',
rate: 700,
for: 'Both',
trackItem: true,
},
];
export function createDummyItems(fyo: Fyo) { type Transfer = {
item: string;
from?: string;
to?: string;
quantity: number;
rate: number;
};
export function getItem(name: string, rate: number) {
return { name, rate, trackItem: true };
}
export async function getStockMovement(
movementType: MovementType,
transfers: Transfer[],
fyo: Fyo
): Promise<StockMovement> {
const doc = fyo.doc.getNewDoc(ModelNameEnum.StockMovement, {
movementType,
}) as StockMovement;
for (const {
item,
from: fromLocation,
to: toLocation,
quantity,
rate,
} of transfers) {
await doc.append('items', {
item,
fromLocation,
toLocation,
rate,
quantity,
});
}
return doc;
}
export async function getSLEs(
referenceName: string,
referenceType: string,
fyo: Fyo
) {
return (await fyo.db.getAllRaw(ModelNameEnum.StockLedgerEntry, {
filters: { referenceName, referenceType },
fields: ['date', 'name', 'item', 'location', 'rate', 'quantity'],
})) as SLE[];
} }

View File

@ -1,24 +1,189 @@
import { ModelNameEnum } from 'models/types'; import { ModelNameEnum } from 'models/types';
import test from 'tape'; import test from 'tape';
import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers'; import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers';
import { dummyItems } from './helpers'; import { StockMovement } from '../StockMovement';
import { MovementType } from '../types';
import { getItem, getSLEs, getStockMovement } from './helpers';
const fyo = getTestFyo(); const fyo = getTestFyo();
setupTestFyo(fyo, __filename); setupTestFyo(fyo, __filename);
test('create dummy items', async (t) => { const itemMap = {
for (const item of dummyItems) { Pen: {
const doc = fyo.doc.getNewDoc(ModelNameEnum.Item, item); name: 'Pen',
t.ok(await doc.sync(), `${item.name} created`); rate: 700,
},
Ink: {
name: 'Ink',
rate: 50,
},
};
const locationMap = {
LocationOne: 'LocationOne',
LocationTwo: 'LocationTwo',
};
/**
* 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);
await fyo.doc.getNewDoc(ModelNameEnum.Item, item).sync();
t.ok(await fyo.db.exists(ModelNameEnum.Item, name), `${name} exists`);
}
// Create Locations
for (const name of Object.values(locationMap)) {
await fyo.doc.getNewDoc(ModelNameEnum.Location, { name }).sync();
t.ok(await fyo.db.exists(ModelNameEnum.Location, name), `${name} exists`);
} }
}); });
test('check dummy items', async (t) => { /**
for (const { name } of dummyItems) { * Section 2: Test Creation of Stock Movements
const exists = await fyo.db.exists(ModelNameEnum.Item, name); */
t.ok(exists, `${name} exists`);
test('create stock movement, material receipt', async (t) => {
const { rate } = itemMap.Ink;
const quantity = 2;
const amount = rate * quantity;
const stockMovement = await getStockMovement(
MovementType.MaterialReceipt,
[
{
item: itemMap.Ink.name,
to: locationMap.LocationOne,
quantity,
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), 1);
t.equal(sle.item, itemMap.Ink.name);
t.equal(parseFloat(sle.rate), rate);
t.equal(sle.quantity, quantity);
t.equal(sle.location, locationMap.LocationOne);
});
test('create stock movement, material transfer', async (t) => {
const { rate } = itemMap.Ink;
const quantity = 2;
const stockMovement = await getStockMovement(
MovementType.MaterialTransfer,
[
{
item: itemMap.Ink.name,
from: locationMap.LocationOne,
to: locationMap.LocationTwo,
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.Ink.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');
}
} }
}); });
test('create stock movement, material issue', async (t) => {
const { rate } = itemMap.Ink;
const quantity = 2;
const stockMovement = await getStockMovement(
MovementType.MaterialIssue,
[
{
item: itemMap.Ink.name,
from: locationMap.LocationTwo,
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.Ink.name);
t.equal(parseFloat(sle.rate), rate);
t.equal(sle.quantity, -quantity);
t.equal(sle.location, locationMap.LocationTwo);
});
/**
* Section 3: Test Cancellation of Stock Movements
*/
test('cancel stock movement', async (t) => {
const names = (await fyo.db.getAllRaw(ModelNameEnum.StockMovement)) as {
name: string;
}[];
for (const { name } of names) {
const slesBefore = await getSLEs(name, ModelNameEnum.StockMovement, fyo);
const doc = (await fyo.doc.getDoc(
ModelNameEnum.StockMovement,
name
)) as StockMovement;
if (doc.movementType === MovementType.MaterialTransfer) {
t.equal(slesBefore.length, (doc.items?.length ?? 0) * 2);
} else {
t.equal(slesBefore.length, doc.items?.length ?? 0);
}
await doc.cancel();
const slesAfter = await getSLEs(name, ModelNameEnum.StockMovement, fyo);
t.equal(slesAfter.length, 0);
}
});
/**
* Section 4: Test Invalid entries
*/
closeTestFyo(fyo, __filename); closeTestFyo(fyo, __filename);