Merge pull request #1255 from suhailanzar/partial-stock-return

fix: implemented complete partial item return handling
This commit is contained in:
Able k Saju
2025-06-23 14:35:50 +05:30
committed by GitHub
8 changed files with 143 additions and 38 deletions
+65 -8
View File
@@ -24,6 +24,7 @@ import {
getPricingRulesConflicts,
removeLoyaltyPoint,
roundFreeItemQty,
getReturnQtyTotal,
} from 'models/helpers';
import { StockTransfer } from 'models/inventory/StockTransfer';
import { validateBatch } from 'models/inventory/helpers';
@@ -94,6 +95,7 @@ export abstract class Invoice extends Transactional {
isReturned?: boolean;
returnAgainst?: string;
isFullyReturned?: boolean;
pricingRuleDetail?: PricingRuleDetail[];
@@ -196,7 +198,8 @@ export abstract class Invoice extends Transactional {
await this._removeLoyaltyPointEntry();
await this._updateIsItemsReturned();
this.reduceUsedCountOfCoupons();
if (this.schemaName === ModelNameEnum.SalesInvoice)
[await this.updateIsItemsFullyReturned(this)];
return;
}
@@ -456,6 +459,9 @@ export abstract class Invoice extends Transactional {
}, (this.netTotal as Money).abs())
.sub(totalDiscount);
if (this.redeemLoyaltyPoints) {
return this.getLPAddedBaseGrandTotal();
}
return grandTotal;
}
@@ -648,6 +654,8 @@ export abstract class Invoice extends Transactional {
let returnDocItems: DocValueMap[] = [];
const totalQtyOfReturnedItems = await getReturnQtyTotal(this);
const returnBalanceItemsQty = await this.fyo.db.getReturnBalanceItemsQty(
this.schemaName,
this.name
@@ -655,13 +663,16 @@ export abstract class Invoice extends Transactional {
for (const item of docItems) {
if (!returnBalanceItemsQty) {
returnDocItems = docItems;
returnDocItems = docItems.map((docItem) => ({
...docItem,
name: undefined,
quantity: -(totalQtyOfReturnedItems[docItem.item as string] || 0),
}));
for (const row of returnDocItems) {
row.name = undefined;
row.itemDiscountedTotal = await this.getItemsDiscountedTotal(
row as InvoiceItem
);
(row.quantity as number) *= -1;
}
break;
}
@@ -677,6 +688,10 @@ export abstract class Invoice extends Transactional {
const returnedItem: ReturnDocItem | undefined =
returnBalanceItemsQty[item.item as string];
if (!returnedItem) {
continue;
}
let quantity = returnedItem.quantity;
let serialNumber: string | undefined =
returnedItem.serialNumbers?.join('\n');
@@ -703,6 +718,22 @@ export abstract class Invoice extends Transactional {
quantity: quantity,
});
}
returnDocItems.forEach((docItems) => {
const itemName = docItems.item as string;
if (itemName in totalQtyOfReturnedItems) {
docItems.quantity = totalQtyOfReturnedItems[itemName];
}
});
returnDocItems = returnDocItems.filter(
(docItems) => (docItems.quantity as number) > 0
);
returnDocItems.forEach((docItems) => {
docItems.quantity = -(docItems.quantity as number);
});
const returnDocData = {
...docData,
name: undefined,
@@ -745,6 +776,32 @@ export abstract class Invoice extends Transactional {
});
}
async updateIsItemsFullyReturned(doc?: Invoice) {
let sinvDoc;
if (doc?.returnAgainst) {
sinvDoc = await this.fyo.doc.getDoc(
ModelNameEnum.SalesInvoice,
doc.returnAgainst
);
}
const totalQtyOfReturnedItems = await getReturnQtyTotal(
(sinvDoc as Invoice) ?? this
);
const isFullyReturned = Object.values(totalQtyOfReturnedItems).every(
(quantity) => quantity === 0
);
if (!isFullyReturned) {
return;
}
const invoiceDoc = await this.fyo.doc.getDoc(
this.schemaName,
this.returnAgainst
);
await invoiceDoc.setAndSync({ isFullyReturned });
await invoiceDoc.submit();
}
async _updateIsItemsReturned() {
if (!this.isReturn || !this.returnAgainst || this.isQuote) {
return;
@@ -818,6 +875,10 @@ export abstract class Invoice extends Transactional {
this.loyaltyPoints as number
);
if (this.redeemLoyaltyPoints && (this.loyaltyPoints as number) > 0) {
this.grandTotal?.add(totalLotaltyAmount);
}
return this.grandTotal?.sub(totalLotaltyAmount);
}
@@ -910,10 +971,6 @@ export abstract class Invoice extends Transactional {
}
}
if (this.redeemLoyaltyPoints) {
return await this.getLPAddedBaseGrandTotal();
}
return this.baseGrandTotal;
},
dependsOn: ['discountAmount', 'discountPercent'],
+5 -14
View File
@@ -401,16 +401,7 @@ export abstract class InvoiceItem extends Doc {
return getTaxedTotalBeforeDiscounting(totalTaxRate, rate, quantity);
},
dependsOn: [
'itemDiscountAmount',
'itemDiscountPercent',
'itemDiscountedTotal',
'setItemDiscountAmount',
'tax',
'rate',
'quantity',
'item',
],
dependsOn: ['rate', 'quantity', 'item'],
},
stockNotTransferred: {
formula: async () => {
@@ -735,12 +726,12 @@ function getDiscountedTotalBeforeTaxation(
* - if percent: Quantity * Rate (1 - DiscountPercent / 100)
*/
const amount = rate.mul(quantity);
if (setDiscountAmount) {
return amount.sub(itemDiscountAmount);
return rate.sub(itemDiscountAmount).mul(quantity);
} else if (itemDiscountPercent > 0) {
return rate.mul(quantity).percent(itemDiscountPercent);
}
return amount.mul(1 - itemDiscountPercent / 100);
return rate.mul(quantity);
}
function getTaxedTotalAfterDiscounting(
+17 -10
View File
@@ -470,11 +470,9 @@ export class Payment extends Transactional {
for (const row of this.for ?? []) {
if (!this.fyo.singles.AccountingSettings?.enablePartialPayment) {
const amount = !(this.amountPaid as Money).isZero()
? (this.amountPaid as Money)
: (this.amount as Money);
const initialAmount = this.initialAmount as Money;
if (amount.lt(initialAmount) && !amount.eq(initialAmount)) {
const amount = this.amount as Money;
const totalAmount = this.totalAmount as Money;
if (amount.lt(totalAmount)) {
if (this.writeoff?.isZero()) {
this.amount = this.initialAmount;
row.amountPaid = this.fyo.pesa(0);
@@ -705,7 +703,7 @@ export class Payment extends Transactional {
};
validations: ValidationMap = {
amount: (value: DocValue) => {
amount: async (value: DocValue) => {
if ((value as Money).isNegative()) {
throw new ValidationError(
this.fyo.t`Payment amount cannot be less than zero.`
@@ -716,13 +714,22 @@ export class Payment extends Transactional {
return;
}
if (!this.initialAmount) {
this.initialAmount = this.amount as Money;
if (!this.totalAmount) {
for (const row of this.for ?? []) {
const referenceDoc = (await this.fyo.doc.getDoc(
row.referenceType as string,
row.referenceName as string
)) as Invoice;
this.totalAmount = referenceDoc.outstandingAmount?.abs();
}
}
if ((value as Money).gt(this.initialAmount)) {
if ((value as Money).gt(this.totalAmount as Money)) {
this.amount = this.initialAmount;
throw new ValidationError(
this.fyo.t`Payment amount cannot exceed ${this.fyo.format(
this.initialAmount,
this.totalAmount,
'Currency'
)}.`
);
+44 -1
View File
@@ -299,7 +299,6 @@ export function getMakeReturnDocAction(fyo: Fyo): Action {
label: fyo.t`Return`,
group: fyo.t`Create`,
condition: (doc: Doc) =>
!doc.isReturn &&
(!!fyo.singles.AccountingSettings?.enableInvoiceReturns ||
!!fyo.singles.InventorySettings?.enableStockReturns) &&
doc.isSubmitted &&
@@ -737,6 +736,50 @@ export async function addItem<M extends ModelsWithItems>(name: string, doc: M) {
await item.set('item', name);
}
export async function getReturnQtyTotal(
doc: Invoice
): Promise<Record<string, number>> {
const returnDocs = await doc.fyo.db.getAll(doc.schemaName, {
fields: ['*'],
filters: {
returnAgainst: doc.name as string,
},
});
const returnedDocs = await Promise.all(
returnDocs.map((docss) =>
doc.fyo.doc.getDoc(doc.schemaName, docss.name as string)
)
);
const quantitySum: { [key: string]: number } = {};
if ('items' in doc && Array.isArray(doc.items)) {
doc.items.forEach((docItem) => {
const itemName = docItem.item as string;
if (itemName) {
quantitySum[itemName] = (docItem.quantity as number) || 0;
}
});
}
if (!returnedDocs) {
return quantitySum;
}
returnedDocs.forEach((returnedDoc) => {
if (returnedDoc && returnedDoc.items) {
(returnedDoc.items as InvoiceItem[]).forEach((item) => {
const itemName = item.item;
if (itemName && quantitySum.hasOwnProperty(itemName)) {
quantitySum[itemName] =
quantitySum[itemName] - Math.abs(item.quantity as number);
}
});
}
});
return quantitySum;
}
export async function createLoyaltyPointEntry(doc: Invoice) {
const loyaltyProgramDoc = (await doc.fyo.doc.getDoc(
ModelNameEnum.LoyaltyProgram,
+6
View File
@@ -187,6 +187,12 @@
"hidden": true,
"default": false
},
{
"fieldname": "isFullyReturned",
"fieldtype": "Check",
"hidden": true,
"default": false
},
{
"fieldname": "isSyncedWithErp",
"fieldtype": "Check",
+2 -2
View File
@@ -1,6 +1,6 @@
<template>
<Modal class="h-96 w-96" :set-close-listener="false">
<p class="text-center py-4">Redeem Loyalty Points</p>
<p class="text-center py-4 dark:text-gray-100">Redeem Loyalty Points</p>
<hr class="dark:border-gray-800" />
@@ -17,7 +17,7 @@
/>
</svg>
<p>{{ loyaltyPoints }}</p>
<p class="dark:text-gray-100">{{ loyaltyPoints }}</p>
</div>
<Int
+2 -1
View File
@@ -610,7 +610,7 @@ export default defineComponent({
},
async setLoyaltyPoints(value: number) {
this.appliedLoyaltyPoints = value;
this.sinvDoc.redeemLoyaltyPoints = true;
await this.sinvDoc.set('redeemLoyaltyPoints', true);
const totalLotaltyAmount = await getAddedLPWithGrandTotal(
this.fyo,
@@ -623,6 +623,7 @@ export default defineComponent({
.abs();
this.sinvDoc.grandTotal = total;
this.sinvDoc.outstandingAmount = total;
},
async selectedInvoiceName(doc: SalesInvoice) {
const salesInvoiceDoc = (await this.fyo.doc.getDoc(
+2 -2
View File
@@ -212,11 +212,11 @@ export default defineComponent({
const returnedInvoiceNames = allInvoices
.filter((inv) => {
if (inv.isReturned) {
if (inv.isFullyReturned || inv.returnAgainst) {
return false;
}
if (inv.isReturned && !(inv.outstandingAmount as Money).isZero()) {
if (inv.isReturned && !inv.isFullyReturned) {
return true;
}