mirror of
https://github.com/frappe/books.git
synced 2025-02-08 23:18:31 +00:00
Merge pull request #682 from akshayitzme/feat-stock-return
feat: stock return
This commit is contained in:
commit
1a93ab04a5
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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';
|
||||
}
|
||||
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -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.`,
|
||||
|
@ -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 &&
|
||||
|
@ -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: {
|
||||
|
@ -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';
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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 {}
|
||||
|
@ -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',
|
||||
|
@ -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,
|
||||
|
@ -74,6 +74,13 @@
|
||||
"label": "Enable UOM Conversion",
|
||||
"fieldtype": "Check",
|
||||
"section": "Features"
|
||||
},
|
||||
{
|
||||
"fieldname": "enableStockReturns",
|
||||
"label": "Enable Stock Returns",
|
||||
"fieldtype": "Check",
|
||||
"default": false,
|
||||
"section": "Features"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -29,6 +29,13 @@
|
||||
"target": "PurchaseReceiptItem",
|
||||
"required": true,
|
||||
"edit": true
|
||||
},
|
||||
{
|
||||
"fieldname": "returnAgainst",
|
||||
"fieldtype": "Link",
|
||||
"target": "PurchaseReceipt",
|
||||
"label": "Return Against",
|
||||
"section": "References"
|
||||
}
|
||||
],
|
||||
"keywordFields": ["name", "party"]
|
||||
|
@ -30,6 +30,13 @@
|
||||
"required": true,
|
||||
"edit": true,
|
||||
"section": "Items"
|
||||
},
|
||||
{
|
||||
"fieldname": "returnAgainst",
|
||||
"fieldtype": "Link",
|
||||
"target": "Shipment",
|
||||
"label": "Return Against",
|
||||
"section": "References"
|
||||
}
|
||||
],
|
||||
"keywordFields": ["name", "party"]
|
||||
|
@ -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"]
|
||||
|
@ -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';
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user