2
0
mirror of https://github.com/frappe/books.git synced 2024-09-18 18:49:01 +00:00

Merge pull request #682 from akshayitzme/feat-stock-return

feat: stock return
This commit is contained in:
Alan 2023-08-18 03:11:25 -07:00 committed by GitHub
commit 1a93ab04a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 879 additions and 21 deletions

View File

@ -8,6 +8,8 @@ import {
import { ModelNameEnum } from '../../models/types';
import DatabaseCore from './core';
import { BespokeFunction } from './types';
import { DocItem, ReturnDocItem } from 'models/inventory/types';
import { safeParseFloat } from 'utils/index';
export class BespokeQueries {
[key: string]: BespokeFunction;
@ -180,4 +182,193 @@ export class BespokeQueries {
return value[0][Object.keys(value[0])[0]];
}
static async getReturnBalanceItemsQty(
db: DatabaseCore,
schemaName: string,
docName: string
): Promise<Record<string, ReturnDocItem> | undefined> {
const returnDocNames = (
await db.knex!(schemaName)
.select('name')
.where('returnAgainst', docName)
.andWhere('submitted', true)
.andWhere('cancelled', false)
).map((i: { name: string }) => i.name);
if (!returnDocNames.length) {
return;
}
const returnedItems: DocItem[] = await db.knex!(`${schemaName}Item`)
.select('item', 'batch', 'serialNumber')
.sum({ quantity: 'quantity' })
.whereIn('parent', returnDocNames)
.groupBy('item', 'batch', 'serialNumber');
if (!returnedItems.length) {
return;
}
const docItems: DocItem[] = await db.knex!(`${schemaName}Item`)
.select('name', 'item', 'batch', 'serialNumber')
.where('parent', docName)
.groupBy('item', 'batch', 'serialNumber')
.sum({ quantity: 'quantity' });
const docItemsMap = BespokeQueries.#getDocItemMap(docItems);
const returnedItemsMap = BespokeQueries.#getDocItemMap(returnedItems);
const returnBalanceItems = BespokeQueries.#getReturnBalanceItemQtyMap(
docItemsMap,
returnedItemsMap
);
return returnBalanceItems;
}
static #getDocItemMap(docItems: DocItem[]): Record<string, ReturnDocItem> {
const docItemsMap: Record<string, ReturnDocItem> = {};
const batchesMap:
| Record<
string,
{ quantity: number; serialNumbers?: string[] | undefined }
>
| undefined = {};
for (const item of docItems) {
if (!!docItemsMap[item.item]) {
if (item.batch) {
let serialNumbers: string[] | undefined;
if (item.serialNumber) {
serialNumbers = item.serialNumber.split('\n');
docItemsMap[item.item].batches![item.batch] = {
quantity: item.quantity,
serialNumbers,
};
}
docItemsMap[item.item].batches![item.batch] = {
quantity: item.quantity,
serialNumbers,
};
} else {
docItemsMap[item.item].quantity += item.quantity;
}
if (item.serialNumber) {
const serialNumbers: string[] = [];
if (docItemsMap[item.item].serialNumbers) {
serialNumbers.push(...(docItemsMap[item.item].serialNumbers ?? []));
}
serialNumbers.push(...item.serialNumber.split('\n'));
docItemsMap[item.item].serialNumbers = serialNumbers;
}
continue;
}
if (item.batch) {
let serialNumbers: string[] | undefined = undefined;
if (item.serialNumber) {
serialNumbers = item.serialNumber.split('\n');
}
batchesMap[item.batch] = {
serialNumbers,
quantity: item.quantity,
};
}
let serialNumbers: string[] | undefined = undefined;
if (!item.batch && item.serialNumber) {
serialNumbers = item.serialNumber.split('\n');
}
docItemsMap[item.item] = {
serialNumbers,
batches: batchesMap,
quantity: item.quantity,
};
}
return docItemsMap;
}
static #getReturnBalanceItemQtyMap(
docItemsMap: Record<string, ReturnDocItem>,
returnedItemsMap: Record<string, ReturnDocItem>
): Record<string, ReturnDocItem> {
const returnBalanceItems: Record<string, ReturnDocItem> | undefined = {};
const balanceBatchQtyMap:
| Record<
string,
{ quantity: number; serialNumbers: string[] | undefined }
>
| undefined = {};
for (const row in returnedItemsMap) {
const balanceSerialNumbersMap: string[] | undefined = [];
if (!docItemsMap[row]) {
continue;
}
const returnedItem = returnedItemsMap[row];
const docItem = docItemsMap[row];
let balanceQty = 0;
const docItemHasBatch = !!Object.keys(docItem.batches ?? {}).length;
const returnedItemHasBatch = !!Object.keys(returnedItem.batches ?? {})
.length;
if (docItemHasBatch && returnedItemHasBatch && docItem.batches) {
for (const batch in returnedItem.batches) {
const returnedItemQty = Math.abs(
returnedItem.batches[batch].quantity
);
const docBatchItemQty = docItem.batches[batch].quantity;
const balanceQty = returnedItemQty - docBatchItemQty;
const docItemSerialNumbers = docItem.batches[batch].serialNumbers;
const returnItemSerialNumbers =
returnedItem.batches[batch].serialNumbers;
let balanceSerialNumbers: string[] | undefined;
if (docItemSerialNumbers && returnItemSerialNumbers) {
balanceSerialNumbers = docItemSerialNumbers.filter(
(serialNumber: string) =>
returnItemSerialNumbers.indexOf(serialNumber) == -1
);
}
balanceBatchQtyMap[batch] = {
quantity: balanceQty,
serialNumbers: balanceSerialNumbers,
};
}
}
if (docItem.serialNumbers && returnedItem.serialNumbers) {
for (const serialNumber of docItem.serialNumbers) {
if (!returnedItem.serialNumbers.includes(serialNumber)) {
balanceSerialNumbersMap.push(serialNumber);
}
}
}
balanceQty = safeParseFloat(
Math.abs(returnedItem.quantity) - docItemsMap[row].quantity
);
returnBalanceItems[row] = {
quantity: balanceQty,
batches: balanceBatchQtyMap,
serialNumbers: balanceSerialNumbersMap,
};
}
return returnBalanceItems;
}
}

View File

@ -26,6 +26,7 @@ import {
DocValueMap,
RawValueMap,
} from './types';
import { ReturnDocItem } from 'models/inventory/types';
type FieldMap = Record<string, Record<string, Field>>;
@ -330,6 +331,17 @@ export class DatabaseHandler extends DatabaseBase {
)) as number | null;
}
async getReturnBalanceItemsQty(
schemaName: string,
docName: string
): Promise<Record<string, ReturnDocItem> | undefined> {
return (await this.#demux.callBespoke(
'getReturnBalanceItemsQty',
schemaName,
docName
)) as Promise<Record<string, ReturnDocItem> | undefined>;
}
/**
* Internal methods
*/

View File

@ -34,6 +34,7 @@ export function getStockTransferActions(
getMakeInvoiceAction(fyo, schemaName),
getLedgerLinkAction(fyo, false),
getLedgerLinkAction(fyo, true),
getMakeReturnDocAction(fyo),
];
}
@ -160,6 +161,26 @@ export function getLedgerLink(
},
};
}
export function getMakeReturnDocAction(fyo: Fyo): Action {
return {
label: fyo.t`Return`,
group: fyo.t`Create`,
condition: (doc: Doc) =>
!!fyo.singles.InventorySettings?.enableStockReturns &&
doc.isSubmitted &&
!doc.isReturn,
action: async (doc: Doc) => {
const returnDoc = await (doc as StockTransfer).getReturnDoc();
if (!returnDoc || !returnDoc.name) {
return;
}
const { routeTo } = await import('src/utils/ui');
const path = `/edit/${doc.schemaName}/${returnDoc.name}`;
await routeTo(path);
},
};
}
export function getTransactionStatusColumn(): ColumnConfig {
return {
@ -190,6 +211,8 @@ export const statusColor: Record<
NotSaved: 'gray',
Submitted: 'green',
Cancelled: 'red',
Return: 'green',
ReturnIssued: 'green',
};
export function getStatusText(status: DocStatus | InvoiceStatus): string {
@ -208,6 +231,10 @@ export function getStatusText(status: DocStatus | InvoiceStatus): string {
return t`Paid`;
case 'Unpaid':
return t`Unpaid`;
case 'Return':
return t`Return`;
case 'ReturnIssued':
return t`Return Issued`;
default:
return '';
}
@ -244,6 +271,20 @@ function getSubmittableDocStatus(doc: RenderData | Doc) {
return getInvoiceStatus(doc);
}
if (
[ModelNameEnum.Shipment, ModelNameEnum.PurchaseReceipt].includes(
doc.schema.name as ModelNameEnum
)
) {
if (!!doc.returnAgainst && doc.submitted && !doc.cancelled) {
return 'Return';
}
if (doc.isReturned && doc.submitted && !doc.cancelled) {
return 'ReturnIssued';
}
}
if (!!doc.submitted && !doc.cancelled) {
return 'Submitted';
}

View File

@ -13,6 +13,7 @@ export class InventorySettings extends Doc {
enableBatches?: boolean;
enableSerialNumber?: boolean;
enableUomConversions?: boolean;
enableStockReturns?: boolean;
static filters: FiltersMap = {
stockInHand: () => ({
@ -42,5 +43,8 @@ export class InventorySettings extends Doc {
enableUomConversions: () => {
return !!this.enableUomConversions;
},
enableStockReturns: () => {
return !!this.enableStockReturns;
},
};
}

View File

@ -56,12 +56,13 @@ export class StockManager {
async validateCancel(transferDetails: SMTransferDetails[]) {
const reverseTransferDetails = transferDetails.map(
({ item, rate, quantity, fromLocation, toLocation }) => ({
({ item, rate, quantity, fromLocation, toLocation, isReturn }) => ({
item,
rate,
quantity,
fromLocation: toLocation,
toLocation: fromLocation,
isReturn,
})
);
await this.validateTransfers(reverseTransferDetails);
@ -94,8 +95,7 @@ export class StockManager {
if (!details.quantity) {
throw new ValidationError(t`Quantity needs to be set`);
}
if (details.quantity <= 0) {
if (!details.isReturn && details.quantity <= 0) {
throw new ValidationError(
t`Quantity (${details.quantity}) has to be greater than zero`
);
@ -152,7 +152,7 @@ export class StockManager {
const batchMessage = !!batch ? t` in Batch ${batch}` : '';
if (quantityBefore < details.quantity) {
if (!details.isReturn && quantityBefore < details.quantity) {
throw new ValidationError(
[
t`Insufficient Quantity.`,

View File

@ -1,5 +1,5 @@
import { t } from 'fyo';
import { Attachment } from 'fyo/core/types';
import { Attachment, DocValueMap } from 'fyo/core/types';
import { Doc } from 'fyo/model/doc';
import {
ChangeArg,
@ -26,6 +26,7 @@ import {
validateBatch,
validateSerialNumber,
} from './helpers';
import { ReturnDocItem } from './types';
export abstract class StockTransfer extends Transfer {
name?: string;
@ -36,11 +37,17 @@ export abstract class StockTransfer extends Transfer {
grandTotal?: Money;
backReference?: string;
items?: StockTransferItem[];
isReturned?: boolean;
returnAgainst?: string;
get isSales() {
return this.schemaName === ModelNameEnum.Shipment;
}
get isReturn(): boolean {
return !!this.returnAgainst && this.returnAgainst.length > 1;
}
get invoiceSchemaName() {
if (this.isSales) {
return ModelNameEnum.SalesInvoice;
@ -61,6 +68,7 @@ export abstract class StockTransfer extends Transfer {
terms: () => !(this.terms || !(this.isSubmitted || this.isCancelled)),
attachment: () =>
!(this.attachment || !(this.isSubmitted || this.isCancelled)),
returnAgainst: () => this.isSubmitted && !this.returnAgainst,
};
static defaults: DefaultMap = {
@ -105,6 +113,7 @@ export abstract class StockTransfer extends Transfer {
quantity: row.quantity!,
batch: row.batch!,
serialNumber: row.serialNumber!,
isReturn: row.isReturn,
fromLocation,
toLocation,
};
@ -127,16 +136,26 @@ export abstract class StockTransfer extends Transfer {
'costOfGoodsSold'
)) as string;
await posting.debit(costOfGoodsSold, amount);
await posting.credit(stockInHand, amount);
if (this.isReturn) {
await posting.debit(stockInHand, amount);
await posting.credit(costOfGoodsSold, amount);
} else {
await posting.debit(costOfGoodsSold, amount);
await posting.credit(stockInHand, amount);
}
} else {
const stockReceivedButNotBilled = (await this.fyo.getValue(
ModelNameEnum.InventorySettings,
'stockReceivedButNotBilled'
)) as string;
await posting.debit(stockInHand, amount);
await posting.credit(stockReceivedButNotBilled, amount);
if (this.isReturn) {
await posting.debit(stockReceivedButNotBilled, amount);
await posting.credit(stockInHand, amount);
} else {
await posting.debit(stockInHand, amount);
await posting.credit(stockReceivedButNotBilled, amount);
}
}
await posting.makeRoundOffEntry();
@ -182,14 +201,21 @@ export abstract class StockTransfer extends Transfer {
async afterSubmit() {
await super.afterSubmit();
await updateSerialNumbers(this, false);
await updateSerialNumbers(this, false, this.isReturn);
await this._updateBackReference();
await this._updateItemsReturned();
}
async beforeCancel(): Promise<void> {
await super.beforeCancel();
await this._validateHasReturnDocs();
}
async afterCancel(): Promise<void> {
await super.afterCancel();
await updateSerialNumbers(this, true);
await updateSerialNumbers(this, true, this.isReturn);
await this._updateBackReference();
await this._updateItemsReturned();
}
async _updateBackReference() {
@ -246,6 +272,47 @@ export abstract class StockTransfer extends Transfer {
await invoice.setAndSync('stockNotTransferred', notTransferred);
}
async _updateItemsReturned() {
if (this.isSyncing || !this.returnAgainst) {
return;
}
const linkedReference = await this.loadAndGetLink('returnAgainst');
if (!linkedReference) {
return;
}
const referenceDoc = await this.fyo.doc.getDoc(
this.schemaName,
linkedReference.name
);
const isReturned = this.isSubmitted;
await referenceDoc.setAndSync({ isReturned });
}
async _validateHasReturnDocs() {
if (!this.name) {
return;
}
const returnDocs = await this.fyo.db.getAll(this.schemaName, {
filters: { returnAgainst: this.name },
});
const hasReturnDocs = !!returnDocs.length;
if (!hasReturnDocs) {
return;
}
const returnDocNames = returnDocs.map((doc) => doc.name).join(', ');
const label = this.fyo.schemaMap[this.schemaName]?.label ?? this.schemaName;
throw new ValidationError(
t`Cannot cancel ${this.schema.label} ${this.name} because of the following ${label}: ${returnDocNames}`
);
}
_getTransferMap() {
return (this.items ?? []).reduce((acc, item) => {
if (!item.item) {
@ -373,6 +440,90 @@ export abstract class StockTransfer extends Transfer {
return invoice;
}
async getReturnDoc(): Promise<StockTransfer | undefined> {
if (!this.name) {
return;
}
const docData = this.getValidDict(true, true);
const docItems = docData.items as DocValueMap[];
if (!docItems) {
return;
}
let returnDocItems: DocValueMap[] = [];
const returnBalanceItemsQty = await this.fyo.db.getReturnBalanceItemsQty(
this.schemaName,
this.name
);
for (const item of docItems) {
if (!returnBalanceItemsQty) {
returnDocItems = docItems;
returnDocItems.map((row) => {
row.name = undefined;
(row.quantity as number) *= -1;
return row;
});
break;
}
const isItemExist = !!returnDocItems.filter(
(balanceItem) => balanceItem.item === item.item
).length;
if (isItemExist) {
continue;
}
const returnedItem: ReturnDocItem | undefined =
returnBalanceItemsQty[item.item as string];
let quantity = returnedItem.quantity;
let serialNumber: string | undefined =
returnedItem.serialNumbers?.join('\n');
if (
item.batch &&
returnedItem.batches &&
returnedItem.batches[item.batch as string]
) {
quantity = returnedItem.batches[item.batch as string].quantity;
if (returnedItem.batches[item.batch as string].serialNumbers) {
serialNumber =
returnedItem.batches[item.batch as string].serialNumbers?.join(
'\n'
);
}
}
returnDocItems.push({
...item,
serialNumber,
name: undefined,
quantity: quantity,
});
}
const returnDocData = {
...docData,
name: undefined,
date: new Date(),
items: returnDocItems,
returnAgainst: docData.name,
} as DocValueMap;
const newReturnDoc = this.fyo.doc.getNewDoc(
this.schema.name,
returnDocData
) as StockTransfer;
await newReturnDoc.runFormulas();
return newReturnDoc;
}
}
async function validateSerialNumberStatus(doc: StockTransfer) {
@ -396,6 +547,12 @@ async function validateSerialNumberStatus(doc: StockTransfer) {
}
const status = snDoc.status ?? 'Inactive';
const isSubmitted = !!doc.isSubmitted;
const isReturn = !!doc.returnAgainst;
if (isSubmitted || isReturn) {
return;
}
if (
doc.schemaName === ModelNameEnum.PurchaseReceipt &&

View File

@ -38,6 +38,10 @@ export class StockTransferItem extends TransferItem {
return this.schemaName === ModelNameEnum.ShipmentItem;
}
get isReturn(): boolean {
return !!this.parentdoc?.isReturn;
}
formulas: FormulaMap = {
description: {
formula: async () =>
@ -94,6 +98,15 @@ export class StockTransferItem extends TransferItem {
const unitDoc = itemDoc.getLink('uom');
let quantity: number = this.quantity ?? 1;
if (this.isReturn && quantity > 0) {
quantity *= -1;
}
if (!this.isReturn && quantity < 0) {
quantity *= -1;
}
if (fieldname === 'transferQuantity') {
quantity = this.transferQuantity! * this.unitConversionFactor!;
}
@ -109,6 +122,7 @@ export class StockTransferItem extends TransferItem {
'transferQuantity',
'transferUnit',
'unitConversionFactor',
'isReturn',
],
},
unitConversionFactor: {

View File

@ -102,7 +102,7 @@ async function validateItemRowSerialNumber(
const serialNumbers = getSerialNumbers(serialNumber);
const quantity = row.quantity ?? 0;
const quantity = Math.abs(row.quantity ?? 0);
if (serialNumbers.length !== quantity) {
throw new ValidationError(
t`Additional ${
@ -147,14 +147,26 @@ async function validateItemRowSerialNumber(
const status = snDoc.status ?? 'Inactive';
const schemaName = row.parentSchemaName;
const isReturn = !!row.parentdoc?.returnAgainst;
const isSubmitted = !!row.parentdoc?.submitted;
if (schemaName === 'PurchaseReceipt' && status !== 'Inactive') {
if (
schemaName === 'PurchaseReceipt' &&
status !== 'Inactive' &&
!isSubmitted &&
!isReturn
) {
throw new ValidationError(
t`Serial Number ${serialNumber} is not Inactive`
);
}
if (schemaName === 'Shipment' && status !== 'Active') {
if (
schemaName === 'Shipment' &&
status !== 'Active' &&
!isSubmitted &&
!isReturn
) {
throw new ValidationError(
t`Serial Number ${serialNumber} is not Active.`
);
@ -244,14 +256,15 @@ export async function canValidateSerialNumber(
export async function updateSerialNumbers(
doc: StockTransfer | StockMovement,
isCancel: boolean
isCancel: boolean,
isReturn = false
) {
for (const row of doc.items ?? []) {
if (!row.serialNumber) {
continue;
}
const status = getSerialNumberStatus(doc, row, isCancel);
const status = getSerialNumberStatus(doc, row, isCancel, isReturn);
await updateSerialNumberStatus(status, row.serialNumber, doc.fyo);
}
}
@ -270,13 +283,20 @@ async function updateSerialNumberStatus(
function getSerialNumberStatus(
doc: StockTransfer | StockMovement,
item: StockTransferItem | StockMovementItem,
isCancel: boolean
isCancel: boolean,
isReturn: boolean
): SerialNumberStatus {
if (doc.schemaName === ModelNameEnum.Shipment) {
if (isReturn) {
return isCancel ? 'Delivered' : 'Active';
}
return isCancel ? 'Active' : 'Delivered';
}
if (doc.schemaName === ModelNameEnum.PurchaseReceipt) {
if (isReturn) {
return isCancel ? 'Active' : 'Delivered';
}
return isCancel ? 'Inactive' : 'Active';
}

View File

@ -10,8 +10,9 @@ import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers';
import { InventorySettings } from '../InventorySettings';
import { Shipment } from '../Shipment';
import { StockTransfer } from '../StockTransfer';
import { ValuationMethod } from '../types';
import { MovementTypeEnum, ValuationMethod } from '../types';
import { getALEs, getItem, getSLEs, getStockTransfer } from './helpers';
import { PurchaseReceipt } from '../PurchaseReceipt';
const fyo = getTestFyo();
setupTestFyo(fyo, __filename);
@ -585,4 +586,357 @@ test('Create Shipment from manually set Back Ref', async (t) => {
t.equal(sinv.stockNotTransferred, 0, 'stock has been transferred');
});
test('Create Shipment then create return against it', async (t) => {
const rate = testDocs.Item[item].rate as number;
const shpm = fyo.doc.getNewDoc(ModelNameEnum.Shipment) as Shipment;
await shpm.set({
party,
date: new Date('2023-05-18'),
items: [{ item, quantity: 3, rate }],
});
await shpm.sync();
await shpm.submit();
t.equal(shpm.name, 'SHPM-1006', 'Shipment created');
const shpmReturn = (await shpm.getReturnDoc()) as Shipment;
await shpmReturn.sync();
await shpmReturn.submit();
t.equal(shpmReturn.name, 'SHPM-1007', 'Shipment return created');
t.ok(
shpmReturn.grandTotal?.isNegative(),
'Shipment return doc has negative grand total '
);
const returnShpmAles = await fyo.db.getAllRaw(
ModelNameEnum.AccountingLedgerEntry,
{
fields: ['name', 'account', 'credit', 'debit'],
filters: { referenceName: shpmReturn.name! },
}
);
for (const ale of returnShpmAles) {
if (ale.account === 'Stock In Hand') {
t.equal(
fyo.pesa(ale.debit as string).float,
shpmReturn.grandTotal?.float,
`Shipment return amount debited from ${ale.account}`
);
}
if (ale.account === 'Cost of Goods Sold') {
t.equal(
fyo.pesa(ale.credit as string).float,
shpmReturn.grandTotal?.float,
`Shipment return amount credited to ${ale.account}`
);
}
}
});
test('Create Shipment return of batched item', async (t) => {
const item = {
name: 'Jacket-B',
rate: fyo.pesa(100),
trackItem: true,
hasBatch: true,
};
const newItemDoc = fyo.doc.getNewDoc(ModelNameEnum.Item, item);
newItemDoc.sync();
newItemDoc.submit();
t.ok(
fyo.db.exists(ModelNameEnum.Item, item.name),
`item ${item.name} created`
);
const batches = [
{
name: 'JKT-A-2',
expiryDate: new Date('2023-07-27T18:30:00.435Z'),
manufactureDate: new Date('2023-07-27T18:30:00.435Z'),
},
{
name: 'JKT-B-2',
expiryDate: new Date('2023-07-27T18:30:00.435Z'),
manufactureDate: new Date('2023-07-27T18:30:00.435Z'),
},
];
for (const batch of batches) {
const batchDoc = fyo.doc.getNewDoc(ModelNameEnum.Batch, batch);
batchDoc.sync();
t.ok(
fyo.db.exists(ModelNameEnum.Batch, batch.name),
`Batch ${batch.name} created`
);
}
const smovDoc = fyo.doc.getNewDoc(ModelNameEnum.StockMovement, {
date: new Date('2023-07-27T18:30:00.435Z'),
movementType: MovementTypeEnum.MaterialReceipt,
});
await smovDoc.append('items', {
item: item.name,
quantity: 4,
rate: item.rate,
toLocation: 'Stores',
batch: batches[0].name,
});
await smovDoc.append('items', {
item: item.name,
quantity: 8,
rate: item.rate,
toLocation: 'Stores',
batch: batches[1].name,
});
await smovDoc.sync();
await smovDoc.submit();
t.equal(smovDoc.name, 'SMOV-1001', 'stock movement created');
const shipmentDoc = fyo.doc.getNewDoc(ModelNameEnum.Shipment) as Shipment;
await shipmentDoc.set({
party,
date: new Date(),
items: [
{ item: item.name, quantity: 4, rate: item.rate, batch: batches[0].name },
{ item: item.name, quantity: 8, rate: item.rate, batch: batches[1].name },
],
});
await shipmentDoc.sync();
await shipmentDoc.submit();
t.equal(shipmentDoc.name, 'SHPM-1008', `Shipment created`);
const shpmReturnDoc = fyo.doc.getNewDoc(ModelNameEnum.Shipment) as Shipment;
await shpmReturnDoc.set({
date: new Date(),
party,
returnAgainst: shipmentDoc.name,
items: [
{ item: item.name, quantity: 2, rate: item.rate, batch: batches[0].name },
{ item: item.name, quantity: 4, rate: item.rate, batch: batches[1].name },
],
});
await shpmReturnDoc.sync();
await shpmReturnDoc.submit();
t.equal(shpmReturnDoc.name, 'SHPM-1009', 'Shipment return is created');
const secondReturnDoc = (await shipmentDoc.getReturnDoc()) as Shipment;
for (const item of secondReturnDoc.items!) {
if (item.batch == batches[0].name) {
const docItemQty = shipmentDoc.items![0].quantity as number;
const retItemQty = shpmReturnDoc.items![0].quantity as number;
const balanceQty = retItemQty - docItemQty;
t.equal(
item.quantity,
balanceQty,
`Batch ${item.batch} has ${balanceQty} qty left to return`
);
}
if (item.batch == batches[1].name) {
const docItemQty = shipmentDoc.items![1].quantity as number;
const retItemQty = shpmReturnDoc.items![1].quantity as number;
const balanceQty = retItemQty - docItemQty;
t.equal(
item.quantity,
balanceQty,
`Batch ${item.batch} has ${balanceQty} qty left to return`
);
}
}
});
test('Create Purchase Reciept then create return against it', async (t) => {
const rate = testDocs.Item[item].rate as number;
const prec = fyo.doc.getNewDoc(
ModelNameEnum.PurchaseReceipt
) as PurchaseReceipt;
await prec.set({
party,
date: new Date('2023-05-18'),
items: [{ item, quantity: 3, rate }],
});
await prec.sync();
await prec.submit();
t.equal(prec.name, 'PREC-1005', 'Purchase Receipt created');
const precReturn = (await prec.getReturnDoc()) as PurchaseReceipt;
await precReturn.sync();
await precReturn.submit();
t.equal(precReturn.name, 'PREC-1006', 'Purchase Receipt return created');
t.ok(
precReturn.grandTotal?.isNegative(),
'Purchase Receipt return doc has negative grand total '
);
const returnPrecAles = await fyo.db.getAllRaw(
ModelNameEnum.AccountingLedgerEntry,
{
fields: ['name', 'account', 'credit', 'debit'],
filters: { referenceName: precReturn.name! },
}
);
for (const ale of returnPrecAles) {
if (ale.account === 'Stock In Hand') {
t.equal(
fyo.pesa(ale.credit as string).float,
precReturn.grandTotal?.float,
`Purchase Receipt return amount credited to ${ale.account}`
);
}
if (ale.account === 'Stock Received But Not Billed') {
t.equal(
fyo.pesa(ale.debit as string).float,
precReturn.grandTotal?.float,
`Purchase Receipt return amount debited from ${ale.account}`
);
}
}
});
test('Create Purchase Reciept return of serialized item', async (t) => {
const item = {
name: 'Jacket-S',
rate: fyo.pesa(100),
trackItem: true,
hasSerialNumber: true,
};
const serialNumbers = {
row1: ['JKT-S-A-001', 'JKT-S-A-002'],
row2: ['JKT-S-B-001', 'JKT-S-B-002'],
};
const newItemDoc = fyo.doc.getNewDoc(ModelNameEnum.Item, item);
newItemDoc.sync();
newItemDoc.submit();
t.ok(
fyo.db.exists(ModelNameEnum.Item, item.name),
`item ${item.name} created`
);
const precDoc = fyo.doc.getNewDoc(ModelNameEnum.PurchaseReceipt, {
date: new Date('2023-07-27T18:30:00.435Z'),
party,
}) as PurchaseReceipt;
await precDoc.append('items', {
item: item.name,
quantity: 2,
rate: item.rate,
toLocation: 'Stores',
serialNumber: serialNumbers.row1.join('\n'),
});
await precDoc.append('items', {
item: item.name,
quantity: 2,
rate: item.rate,
toLocation: 'Stores',
serialNumber: serialNumbers.row2.join('\n'),
});
await precDoc.sync();
await precDoc.submit();
t.equal(
precDoc.name,
'PREC-1007',
'purchase reciept having serial number created'
);
const returnPrecDoc = fyo.doc.getNewDoc(ModelNameEnum.PurchaseReceipt, {
date: new Date('2023-07-27T18:30:00.435Z'),
party,
returnAgainst: precDoc.name,
}) as PurchaseReceipt;
await returnPrecDoc.append('items', {
item: item.name,
quantity: 1,
rate: item.rate,
toLocation: 'Stores',
serialNumber: serialNumbers.row1[0],
});
await returnPrecDoc.append('items', {
item: item.name,
quantity: 1,
rate: item.rate,
toLocation: 'Stores',
serialNumber: serialNumbers.row2[0],
});
await returnPrecDoc.sync();
await returnPrecDoc.submit();
t.equal(
returnPrecDoc.name,
'PREC-1008',
'purchase reciept return having serial number created'
);
const secondPrecReturnDoc = (await precDoc.getReturnDoc()) as PurchaseReceipt;
const returnBalSerialNumbers = secondPrecReturnDoc.items
?.map((item) => item.serialNumber?.split('\n'))
.flat();
t.ok(returnBalSerialNumbers?.length === 2);
const returnedSerialNumbers = [
returnPrecDoc.items![0].serialNumber,
returnPrecDoc.items![1].serialNumber,
];
for (const serialNumber of returnedSerialNumbers) {
const status = await fyo.getValue(
ModelNameEnum.SerialNumber,
serialNumber as string,
'status'
);
t.equal(
status,
'Delivered',
'serial number status updated to Delivered after return'
);
}
for (const serialNumber of returnBalSerialNumbers!) {
const status = await fyo.getValue(
ModelNameEnum.SerialNumber,
serialNumber as string,
'status'
);
t.equal(
status,
'Active',
'serial number status is Active for unreturned item'
);
}
});
closeTestFyo(fyo, __filename);

View File

@ -37,6 +37,27 @@ export interface SMTransferDetails {
serialNumber?: string;
fromLocation?: string;
toLocation?: string;
isReturn?: boolean;
}
export interface ReturnBalanceItemQty {
item?: string;
quantity: number;
batch?: string | undefined;
serialNumber?: string;
}
export interface DocItem {
item: string;
quantity: number;
batch?: string | undefined;
serialNumber?: string;
}
export interface ReturnDocItem {
quantity: number;
batches?: Record<string, { quantity: number, serialNumbers?: string[] }> | undefined;
serialNumbers?: string[] | undefined;
}
export interface SMIDetails extends SMDetails, SMTransferDetails {}

View File

@ -1,4 +1,4 @@
export type InvoiceStatus = 'Draft' | 'Saved' | 'Unpaid' | 'Cancelled' | 'Paid';
export type InvoiceStatus = 'Draft' | 'Saved' | 'Unpaid' | 'Cancelled' | 'Paid' | 'Return' | 'ReturnIssued';
export enum ModelNameEnum {
Account = 'Account',
AccountingLedgerEntry = 'AccountingLedgerEntry',

View File

@ -105,8 +105,8 @@ export abstract class LedgerReport extends Report {
name: safeParseInt(entry.name),
account: entry.account,
date: new Date(entry.date),
debit: safeParseFloat(entry.debit),
credit: safeParseFloat(entry.credit),
debit: Math.abs(safeParseFloat(entry.debit)),
credit: Math.abs(safeParseFloat(entry.credit)),
balance: 0,
referenceType: entry.referenceType,
referenceName: entry.referenceName,

View File

@ -74,6 +74,13 @@
"label": "Enable UOM Conversion",
"fieldtype": "Check",
"section": "Features"
},
{
"fieldname": "enableStockReturns",
"label": "Enable Stock Returns",
"fieldtype": "Check",
"default": false,
"section": "Features"
}
]
}

View File

@ -29,6 +29,13 @@
"target": "PurchaseReceiptItem",
"required": true,
"edit": true
},
{
"fieldname": "returnAgainst",
"fieldtype": "Link",
"target": "PurchaseReceipt",
"label": "Return Against",
"section": "References"
}
],
"keywordFields": ["name", "party"]

View File

@ -30,6 +30,13 @@
"required": true,
"edit": true,
"section": "Items"
},
{
"fieldname": "returnAgainst",
"fieldtype": "Link",
"target": "Shipment",
"label": "Return Against",
"section": "References"
}
],
"keywordFields": ["name", "party"]

View File

@ -61,10 +61,21 @@
"fieldtype": "Attachment",
"section": "References"
},
{
"fieldname": "isReturned",
"fieldtype": "Check",
"hidden": true,
"default": false
},
{
"abstract": true,
"fieldname": "backReference",
"section": "References"
},
{
"abstract": true,
"fieldname": "returnAgainst",
"section": "References"
}
],
"keywordFields": ["name", "party"]

View File

@ -43,6 +43,8 @@ export default defineComponent({
Paid: this.t`Paid`,
Saved: this.t`Saved`,
Submitted: this.t`Submitted`,
Return: this.t`Return`,
ReturnIssued: this.t`Return Issued`,
}[this.status];
},
color(): UIColors {
@ -61,6 +63,8 @@ const statusColorMap: Record<Status, UIColors> = {
Paid: 'green',
Saved: 'blue',
Submitted: 'blue',
Return: 'green',
ReturnIssued: 'green',
};
function getStatus(doc: Doc) {
@ -109,6 +113,14 @@ function getSubmittableStatus(doc: Doc) {
return 'Paid';
}
if (doc.returnAgainst && doc.isSubmitted) {
return 'Return';
}
if (doc.isReturned && doc.isSubmitted) {
return 'ReturnIssued';
}
if (doc.isSubmitted) {
return 'Submitted';
}