mirror of
https://github.com/frappe/books.git
synced 2026-08-24 10:04:45 -05:00
Merge branch 'master' into Integrationerrorlog
This commit is contained in:
@@ -28,6 +28,9 @@ import {
|
||||
getReturnLoyaltyPoints,
|
||||
getItemQtyMap,
|
||||
getItemVisibility,
|
||||
validateLoyaltyProgram,
|
||||
getLoyaltyProgramTier,
|
||||
isLoyaltyProgramExpiredAndMaxed,
|
||||
} from 'models/helpers';
|
||||
import { StockTransfer } from 'models/inventory/StockTransfer';
|
||||
import { validateBatch } from 'models/inventory/helpers';
|
||||
@@ -196,6 +199,23 @@ export abstract class Invoice extends Transactional {
|
||||
if (this.isQuote) {
|
||||
return;
|
||||
}
|
||||
if (!this.submitted && this.loyaltyProgram) {
|
||||
const isExpiredOrMaxed = await isLoyaltyProgramExpiredAndMaxed(
|
||||
this.fyo,
|
||||
this.loyaltyProgram
|
||||
);
|
||||
|
||||
if (isExpiredOrMaxed) {
|
||||
const { showToast } = await import('src/utils/interactive');
|
||||
|
||||
showToast({
|
||||
type: 'warning',
|
||||
message: t`Loyalty program has expired or reached maximum usage`,
|
||||
duration: 'short',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
this.enableDiscounting &&
|
||||
!this.fyo.singles?.AccountingSettings?.discountAccount
|
||||
@@ -212,13 +232,49 @@ export abstract class Invoice extends Transactional {
|
||||
this.party
|
||||
)) as Party;
|
||||
|
||||
if ((this.loyaltyPoints as number) > (partyDoc?.loyaltyPoints || 0)) {
|
||||
if (this.redeemLoyaltyPoints && (this.loyaltyPoints as number) > 0) {
|
||||
const currentPoints = partyDoc?.loyaltyPoints || 0;
|
||||
|
||||
let pointsToBeEarned = 0;
|
||||
if (!this.isReturn && this.loyaltyProgram) {
|
||||
const loyaltyProgramDoc = (await this.fyo.doc.getDoc(
|
||||
ModelNameEnum.LoyaltyProgram,
|
||||
this.loyaltyProgram
|
||||
)) as LoyaltyProgram;
|
||||
|
||||
const tier = getLoyaltyProgramTier(
|
||||
loyaltyProgramDoc,
|
||||
this?.grandTotal as Money
|
||||
);
|
||||
|
||||
if (tier) {
|
||||
const collectionFactor = tier.collectionFactor as number;
|
||||
pointsToBeEarned =
|
||||
Math.round(this?.grandTotal?.float || 0) * collectionFactor;
|
||||
}
|
||||
}
|
||||
|
||||
const totalAvailablePoints = currentPoints + pointsToBeEarned;
|
||||
if ((this.loyaltyPoints as number) > totalAvailablePoints) {
|
||||
throw new ValidationError(
|
||||
t`${
|
||||
this.party as string
|
||||
} only has ${currentPoints} points (${pointsToBeEarned} will be earned from this transaction)`
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
(this.loyaltyPoints as number) > (partyDoc?.loyaltyPoints || 0)
|
||||
) {
|
||||
throw new ValidationError(
|
||||
t`${this.party as string} only has ${
|
||||
partyDoc.loyaltyPoints as number
|
||||
} points`
|
||||
);
|
||||
}
|
||||
|
||||
if (this.loyaltyProgram) {
|
||||
await validateLoyaltyProgram(this, this.loyaltyProgram);
|
||||
}
|
||||
}
|
||||
|
||||
async afterSubmit() {
|
||||
@@ -275,6 +331,10 @@ export abstract class Invoice extends Transactional {
|
||||
if (this.schemaName === ModelNameEnum.SalesInvoice) {
|
||||
this.updateUsedCountOfCoupons();
|
||||
}
|
||||
|
||||
if (this.loyaltyProgram) {
|
||||
await this.updateUsedCountOfLoyaltyProgram();
|
||||
}
|
||||
}
|
||||
|
||||
async afterCancel() {
|
||||
@@ -284,6 +344,10 @@ export abstract class Invoice extends Transactional {
|
||||
await this._updateIsItemsReturned();
|
||||
await this._removeLoyaltyPointEntry();
|
||||
this.reduceUsedCountOfCoupons();
|
||||
|
||||
if (this.loyaltyProgram) {
|
||||
await this.reduceUsedCountOfLoyaltyProgram();
|
||||
}
|
||||
}
|
||||
|
||||
async _removeLoyaltyPointEntry() {
|
||||
@@ -712,17 +776,26 @@ export abstract class Invoice extends Transactional {
|
||||
if (item.batch) {
|
||||
const returnData = totalQtyOfReturnedItems[item.item as string];
|
||||
if (typeof returnData === 'object' && returnData?.batches) {
|
||||
returnDocItems = docItems.map((docItem) => ({
|
||||
...docItem,
|
||||
name: undefined,
|
||||
quantity: -returnData?.batches![docItem.batch as string] || 0,
|
||||
}));
|
||||
returnDocItems = docItems.map((docItem: DocValueMap) => {
|
||||
const qty = -returnData?.batches![docItem.batch as string] || 0;
|
||||
const transferQty =
|
||||
qty / ((docItem.unitConversionFactor as number) || 1);
|
||||
return {
|
||||
...docItem,
|
||||
name: undefined,
|
||||
quantity: qty,
|
||||
transferQuantity: transferQty,
|
||||
};
|
||||
});
|
||||
}
|
||||
} else {
|
||||
returnDocItems = docItems.map((docItem) => ({
|
||||
returnDocItems = docItems.map((docItem: DocValueMap) => ({
|
||||
...docItem,
|
||||
name: undefined,
|
||||
quantity: -(totalQtyOfReturnedItems[docItem.item as string] || 0),
|
||||
qty:
|
||||
-(totalQtyOfReturnedItems[docItem.item as string] as number) /
|
||||
(item.unitConversionFactor as number),
|
||||
transferQuantity: -(
|
||||
(totalQtyOfReturnedItems[docItem.item as string] as number) /
|
||||
(item.unitConversionFactor as number)
|
||||
@@ -788,6 +861,7 @@ export abstract class Invoice extends Transactional {
|
||||
serialNumber,
|
||||
name: undefined,
|
||||
quantity: quantity,
|
||||
qty: transferQuantity,
|
||||
transferQuantity,
|
||||
});
|
||||
}
|
||||
@@ -838,6 +912,63 @@ export abstract class Invoice extends Transactional {
|
||||
});
|
||||
}
|
||||
|
||||
async updateUsedCountOfLoyaltyProgram() {
|
||||
if (!this.loyaltyProgram) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loyaltyProgramDoc = await this.fyo.doc.getDoc(
|
||||
ModelNameEnum.LoyaltyProgram,
|
||||
this.loyaltyProgram
|
||||
);
|
||||
|
||||
const maximumUse = loyaltyProgramDoc.maximumUse as number;
|
||||
const used = (loyaltyProgramDoc.used as number) || 0;
|
||||
|
||||
if (this.redeemLoyaltyPoints) {
|
||||
const newUsedCount = used + 1;
|
||||
|
||||
if (maximumUse > 0 && newUsedCount >= maximumUse) {
|
||||
await loyaltyProgramDoc.setAndSync({
|
||||
used: newUsedCount,
|
||||
isEnabled: false,
|
||||
});
|
||||
} else {
|
||||
await loyaltyProgramDoc.setAndSync({
|
||||
used: newUsedCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async reduceUsedCountOfLoyaltyProgram() {
|
||||
if (!this.loyaltyProgram) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loyaltyProgramDoc = await this.fyo.doc.getDoc(
|
||||
ModelNameEnum.LoyaltyProgram,
|
||||
this.loyaltyProgram
|
||||
);
|
||||
|
||||
const maximumUse = loyaltyProgramDoc.maximumUse as number;
|
||||
const used = (loyaltyProgramDoc.used as number) || 0;
|
||||
const newUsedCount = used - 1;
|
||||
|
||||
if (this.redeemLoyaltyPoints) {
|
||||
if (newUsedCount < maximumUse) {
|
||||
await loyaltyProgramDoc.setAndSync({
|
||||
used: newUsedCount,
|
||||
isEnabled: true,
|
||||
});
|
||||
} else {
|
||||
await loyaltyProgramDoc.setAndSync({
|
||||
used: newUsedCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateIsItemsFullyReturned(doc?: Invoice) {
|
||||
if (!doc?.returnAgainst || doc.schemaName !== ModelNameEnum.SalesInvoice) {
|
||||
return;
|
||||
@@ -898,11 +1029,27 @@ export abstract class Invoice extends Transactional {
|
||||
this.loyaltyProgram
|
||||
)) as LoyaltyProgram;
|
||||
|
||||
const expiryDate = this.date as Date;
|
||||
const invoiceDate = this.date as Date;
|
||||
const fromDate = loyaltyProgramDoc.fromDate as Date;
|
||||
const toDate = loyaltyProgramDoc.toDate as Date;
|
||||
|
||||
if (fromDate <= expiryDate && toDate >= expiryDate) {
|
||||
const normalizedInvoiceDate = new Date(invoiceDate);
|
||||
normalizedInvoiceDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const normalizedFromDate = new Date(fromDate);
|
||||
normalizedFromDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const normalizedToDate = new Date(toDate);
|
||||
normalizedToDate.setHours(0, 0, 0, 0);
|
||||
|
||||
if (normalizedToDate.getTime() < normalizedInvoiceDate.getTime()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedInvoiceDate.getTime() >= normalizedFromDate.getTime() &&
|
||||
normalizedInvoiceDate.getTime() <= normalizedToDate.getTime()
|
||||
) {
|
||||
const party = (await this.loadAndGetLink('party')) as Party;
|
||||
|
||||
await createLoyaltyPointEntry(this);
|
||||
@@ -989,7 +1136,13 @@ export abstract class Invoice extends Transactional {
|
||||
ModelNameEnum.Party,
|
||||
this.party
|
||||
);
|
||||
return partyDoc?.loyaltyProgram as string;
|
||||
const loyaltyProgramName = partyDoc?.loyaltyProgram as string;
|
||||
|
||||
if (!loyaltyProgramName) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return loyaltyProgramName;
|
||||
},
|
||||
dependsOn: ['party', 'name'],
|
||||
},
|
||||
@@ -999,6 +1152,17 @@ export abstract class Invoice extends Transactional {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const loyaltyProgramName = this.loyaltyProgram as string;
|
||||
if (loyaltyProgramName) {
|
||||
const isExpiredAndMaxed = await isLoyaltyProgramExpiredAndMaxed(
|
||||
this.fyo,
|
||||
loyaltyProgramName
|
||||
);
|
||||
if (isExpiredAndMaxed) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
const loyaltyPoints = await this.fyo.getValue(
|
||||
ModelNameEnum.Party,
|
||||
this.party,
|
||||
@@ -1006,7 +1170,7 @@ export abstract class Invoice extends Transactional {
|
||||
);
|
||||
return loyaltyPoints || 0;
|
||||
},
|
||||
dependsOn: ['party'],
|
||||
dependsOn: ['party', 'loyaltyProgram'],
|
||||
},
|
||||
currency: {
|
||||
formula: async () => {
|
||||
@@ -1199,7 +1363,13 @@ export abstract class Invoice extends Transactional {
|
||||
loyaltyProgram: () => !this.loyaltyProgram,
|
||||
availableLoyaltyPoints: () => !this.loyaltyProgram || this.isReturn,
|
||||
loyaltyPoints: () => !this.redeemLoyaltyPoints || this.isReturn,
|
||||
redeemLoyaltyPoints: () => !this.loyaltyProgram || this.isReturn,
|
||||
redeemLoyaltyPoints: () => {
|
||||
if (!this.loyaltyProgram || this.isReturn) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (this.availableLoyaltyPoints ?? 0) <= 0;
|
||||
},
|
||||
coupons: () => this.isSubmitted && !this.coupons?.length,
|
||||
priceList: () =>
|
||||
!this.fyo.singles.AccountingSettings?.enablePriceList ||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DocValue, DocValueMap } from 'fyo/core/types';
|
||||
import { Doc } from 'fyo/model/doc';
|
||||
import {
|
||||
CurrenciesMap,
|
||||
ChangeArg,
|
||||
FiltersMap,
|
||||
FormulaMap,
|
||||
HiddenMap,
|
||||
@@ -19,8 +20,20 @@ import { Item } from '../Item/Item';
|
||||
import { StockTransfer } from 'models/inventory/StockTransfer';
|
||||
import { isPesa } from 'fyo/utils';
|
||||
import { PricingRule } from '../PricingRule/PricingRule';
|
||||
import { getItemRateFromPriceList, getPricingRule } from 'models/helpers';
|
||||
import {
|
||||
getItemRateFromPriceList,
|
||||
getPricingRule,
|
||||
getItemVisibility,
|
||||
} from 'models/helpers';
|
||||
import { SalesInvoice } from '../SalesInvoice/SalesInvoice';
|
||||
import { getSuggestedBatchName } from 'models/inventory/helpers';
|
||||
import { ValuationMethod } from 'models/inventory/types';
|
||||
import {
|
||||
getRawStockLedgerEntries,
|
||||
getStockLedgerEntries,
|
||||
getStockBalanceEntries,
|
||||
} from 'reports/inventory/helpers';
|
||||
import { QueryFilter } from 'utils/db/types';
|
||||
|
||||
export abstract class InvoiceItem extends Doc {
|
||||
item?: string;
|
||||
@@ -107,6 +120,27 @@ export abstract class InvoiceItem extends Doc {
|
||||
this._setGetCurrencies();
|
||||
}
|
||||
|
||||
override async change(ch: ChangeArg): Promise<void> {
|
||||
await super.change(ch);
|
||||
|
||||
if (ch.changed === 'item') {
|
||||
if (!this.isSales && this.item) {
|
||||
const hasBatch = await this.fyo.getValue(
|
||||
ModelNameEnum.Item,
|
||||
this.item,
|
||||
'hasBatch'
|
||||
);
|
||||
|
||||
if (hasBatch) {
|
||||
const batchName = await getSuggestedBatchName(this.fyo, this.item);
|
||||
if (batchName) {
|
||||
await this.set('batch', batchName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getTotalTaxRate(): Promise<number> {
|
||||
if (!this.tax) {
|
||||
return 0;
|
||||
@@ -606,15 +640,115 @@ export abstract class InvoiceItem extends Doc {
|
||||
filters: { uom: value as string, parent: this.item },
|
||||
});
|
||||
|
||||
if (item.length < 1)
|
||||
if (item.length < 1) {
|
||||
throw new ValidationError(
|
||||
t`Transfer Unit ${value as string} is not applicable for Item ${
|
||||
this.item
|
||||
}`
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
qty: async (value: DocValue) => {
|
||||
const requiredQuantity = Math.abs(value as number);
|
||||
|
||||
if (!this.item || requiredQuantity <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isSales) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.fyo.singles.InventorySettings?.enableBatches) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.batch) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.validateBatchQuantity(this.batch, requiredQuantity);
|
||||
},
|
||||
|
||||
batch: async (value: DocValue) => {
|
||||
if (!value || !this.item) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isSales) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.fyo.singles.InventorySettings?.enableBatches) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredQuantity = this.quantity ?? 0;
|
||||
|
||||
if (requiredQuantity > 0) {
|
||||
await this.validateBatchQuantity(value as string, requiredQuantity);
|
||||
} else if (requiredQuantity < 0) {
|
||||
await this.validateBatchQuantity(
|
||||
value as string,
|
||||
Math.abs(requiredQuantity)
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
async validateBatchQuantity(
|
||||
batchName: string,
|
||||
requiredQuantity: number
|
||||
): Promise<void> {
|
||||
let inventoryLocation: string | undefined;
|
||||
|
||||
if (this.location) {
|
||||
inventoryLocation = this.location as string;
|
||||
} else {
|
||||
const posProfileName = this.fyo.singles.POSSettings?.posProfile;
|
||||
|
||||
if (posProfileName) {
|
||||
const inventory = await this.fyo.getValue(
|
||||
ModelNameEnum.POSProfile,
|
||||
posProfileName as string,
|
||||
'inventory'
|
||||
);
|
||||
|
||||
inventoryLocation = inventory as string | undefined;
|
||||
} else {
|
||||
inventoryLocation = this.fyo.singles.POSSettings?.inventory;
|
||||
}
|
||||
}
|
||||
|
||||
const valuationMethod =
|
||||
(this.fyo.singles.InventorySettings
|
||||
?.valuationMethod as ValuationMethod) ?? ValuationMethod.FIFO;
|
||||
|
||||
const rawSLEs = await getRawStockLedgerEntries(this.fyo);
|
||||
const computedSLEs = getStockLedgerEntries(rawSLEs, valuationMethod);
|
||||
|
||||
const stockBalance = getStockBalanceEntries(computedSLEs, {
|
||||
item: this.item!,
|
||||
location: inventoryLocation,
|
||||
batch: batchName,
|
||||
});
|
||||
|
||||
const availableQuantity = stockBalance.reduce(
|
||||
(sum, entry) => sum + (entry.balanceQuantity || 0),
|
||||
0
|
||||
);
|
||||
|
||||
if (requiredQuantity > availableQuantity) {
|
||||
throw new ValidationError(
|
||||
this.fyo.t`
|
||||
Batch ${batchName} only has ${availableQuantity} quantity available
|
||||
but ${requiredQuantity} is required
|
||||
`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
hidden: HiddenMap = {
|
||||
itemDiscountedTotal: () => {
|
||||
if (!this.enableDiscounting) {
|
||||
@@ -646,24 +780,121 @@ export abstract class InvoiceItem extends Doc {
|
||||
};
|
||||
|
||||
static filters: FiltersMap = {
|
||||
item: (doc: Doc) => {
|
||||
item: async (doc: Doc): Promise<QueryFilter> => {
|
||||
let itemNotFor = 'Sales';
|
||||
if (doc.isSales) {
|
||||
itemNotFor = 'Purchases';
|
||||
}
|
||||
|
||||
return { for: ['not in', [itemNotFor]] };
|
||||
const filters: QueryFilter = {
|
||||
for: ['not in', [itemNotFor]],
|
||||
};
|
||||
|
||||
const enableERPNextSync =
|
||||
doc.fyo.singles.AccountingSettings?.enableERPNextSync;
|
||||
|
||||
if (enableERPNextSync) {
|
||||
const itemVisibility = await getItemVisibility(doc.fyo);
|
||||
|
||||
if (itemVisibility === 'Inventory Items') {
|
||||
filters.trackItem = true;
|
||||
} else if (itemVisibility === 'ERP Sync Items') {
|
||||
filters.datafromErp = true;
|
||||
} else if (itemVisibility === 'Non-Inventory Items') {
|
||||
filters.trackItem = false;
|
||||
filters.datafromErp = false;
|
||||
}
|
||||
}
|
||||
|
||||
return filters;
|
||||
},
|
||||
batch: async (doc: Doc) => {
|
||||
const batches = await doc.fyo.db.getAll(ModelNameEnum.Batch, {
|
||||
fields: ['name'],
|
||||
filters: { item: doc.item as string },
|
||||
});
|
||||
const batchName = batches.map((b) => b.name) as string[];
|
||||
const hasBatch = !!(await doc.fyo.getValue(
|
||||
ModelNameEnum.Item,
|
||||
doc.item as string,
|
||||
'hasBatch'
|
||||
));
|
||||
|
||||
return {
|
||||
name: ['in', batchName],
|
||||
};
|
||||
if (!hasBatch) {
|
||||
return { name: ['in', []] };
|
||||
}
|
||||
|
||||
let suggestedBatch: string | undefined;
|
||||
|
||||
if (!doc.isSales) {
|
||||
suggestedBatch = await getSuggestedBatchName(
|
||||
doc.fyo,
|
||||
doc.item as string
|
||||
);
|
||||
|
||||
if (suggestedBatch) {
|
||||
await doc.set('batch', suggestedBatch);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let inventoryLocation: string | undefined;
|
||||
|
||||
if (doc.location) {
|
||||
inventoryLocation = doc.location as string;
|
||||
} else {
|
||||
const posProfileName = doc.fyo.singles.POSSettings?.posProfile;
|
||||
if (posProfileName) {
|
||||
const posProfile = await doc.fyo.doc.getDoc(
|
||||
ModelNameEnum.POSProfile,
|
||||
posProfileName as string
|
||||
);
|
||||
inventoryLocation = posProfile?.inventory as string | undefined;
|
||||
} else {
|
||||
inventoryLocation = doc.fyo.singles.POSSettings?.inventory;
|
||||
}
|
||||
}
|
||||
|
||||
const rawSLEs = await getRawStockLedgerEntries(doc.fyo);
|
||||
|
||||
const valuationMethod =
|
||||
(doc.fyo.singles.InventorySettings
|
||||
?.valuationMethod as ValuationMethod) ?? ValuationMethod.FIFO;
|
||||
|
||||
const computedSLEs = getStockLedgerEntries(rawSLEs, valuationMethod);
|
||||
|
||||
const stockBalance = getStockBalanceEntries(computedSLEs, {
|
||||
item: doc.item as string,
|
||||
location: inventoryLocation,
|
||||
});
|
||||
|
||||
const batchesWithStock = stockBalance
|
||||
.filter((entry) => entry.batch && entry.balanceQuantity > 0)
|
||||
.map((entry) => entry.batch);
|
||||
|
||||
const allBatches = new Set<string>(batchesWithStock);
|
||||
if (suggestedBatch) {
|
||||
allBatches.add(suggestedBatch);
|
||||
}
|
||||
|
||||
const finalBatchList = Array.from(allBatches);
|
||||
|
||||
return {
|
||||
name: ['in', finalBatchList],
|
||||
};
|
||||
} catch (error) {
|
||||
const batches = await doc.fyo.db.getAll(ModelNameEnum.Batch, {
|
||||
fields: ['name'],
|
||||
filters: { item: doc.item as string },
|
||||
});
|
||||
const batchNames = batches.map((b) => b.name) as string[];
|
||||
|
||||
const allBatches = new Set<string>(batchNames);
|
||||
if (suggestedBatch) {
|
||||
allBatches.add(suggestedBatch);
|
||||
}
|
||||
|
||||
const finalBatchList = Array.from(allBatches);
|
||||
|
||||
return {
|
||||
name: ['in', finalBatchList],
|
||||
};
|
||||
}
|
||||
},
|
||||
transferUnit: async (doc: Doc) => {
|
||||
const conversionItems = await doc.fyo.db.getAll(
|
||||
|
||||
@@ -26,10 +26,12 @@ export class Item extends Doc {
|
||||
itemType?: 'Product' | 'Service';
|
||||
for?: 'Purchases' | 'Sales' | 'Both';
|
||||
hasBatch?: boolean;
|
||||
batchSeries?: string;
|
||||
itemGroup?: string;
|
||||
hsnCode?: number;
|
||||
hasSerialNumber?: boolean;
|
||||
serialNumberSeries?: string;
|
||||
datafromErp?: boolean;
|
||||
uomConversions: UOMConversionItem[] = [];
|
||||
|
||||
formulas: FormulaMap = {
|
||||
@@ -100,6 +102,13 @@ export class Item extends Doc {
|
||||
this.serialNumberSeries = series + '-';
|
||||
}
|
||||
}
|
||||
|
||||
if (this.batchSeries && this.hasBatch) {
|
||||
const series = this.batchSeries.trim();
|
||||
if (series && !series.endsWith('-')) {
|
||||
this.batchSeries = series + '-';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async afterSync(): Promise<void> {
|
||||
@@ -125,6 +134,27 @@ export class Item extends Doc {
|
||||
.sync();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.hasBatch && this.batchSeries) {
|
||||
const seriesName = this.batchSeries?.trim();
|
||||
|
||||
if (!seriesName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exists = await this.fyo.db.exists('BatchSeries', seriesName);
|
||||
|
||||
if (!exists) {
|
||||
await this.fyo.doc
|
||||
.getNewDoc('BatchSeries', {
|
||||
name: seriesName,
|
||||
start: 1001,
|
||||
padZeros: 4,
|
||||
current: 1001,
|
||||
})
|
||||
.sync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static filters: FiltersMap = {
|
||||
@@ -173,6 +203,21 @@ export class Item extends Doc {
|
||||
);
|
||||
}
|
||||
},
|
||||
batchSeries: (value: DocValue) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const series = (value as string).trim();
|
||||
const invalidChars = /[/\=\?\&\%]/;
|
||||
|
||||
if (invalidChars.test(series)) {
|
||||
throw new ValidationError(
|
||||
this.fyo
|
||||
.t`Batch Series cannot contain the following characters: /, ?, &, =, %`
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
static getActions(fyo: Fyo): Action[] {
|
||||
@@ -226,6 +271,7 @@ export class Item extends Doc {
|
||||
this.fyo.singles.InventorySettings?.enableSerialNumber && this.trackItem
|
||||
),
|
||||
serialNumberSeries: () => !this.hasSerialNumber,
|
||||
batchSeries: () => !this.hasBatch,
|
||||
uomConversions: () =>
|
||||
!this.fyo.singles.InventorySettings?.enableUomConversions,
|
||||
itemGroup: () => !this.fyo.singles.AccountingSettings?.enableitemGroup,
|
||||
|
||||
@@ -1,22 +1,42 @@
|
||||
import { DocValue } from 'fyo/core/types';
|
||||
import { Doc } from 'fyo/model/doc';
|
||||
import { FiltersMap, ListViewSettings } from 'fyo/model/types';
|
||||
import { ListViewSettings, ValidationMap } from 'fyo/model/types';
|
||||
import { ValidationError } from 'fyo/utils/errors';
|
||||
import { CollectionRulesItems } from '../CollectionRulesItems/CollectionRulesItems';
|
||||
import { AccountRootTypeEnum } from '../Account/types';
|
||||
import { getLoyaltyProgramStatusColumn } from '../../helpers';
|
||||
|
||||
export class LoyaltyProgram extends Doc {
|
||||
collectionRules?: CollectionRulesItems[];
|
||||
expiryDuration?: number;
|
||||
maximumUse?: number;
|
||||
used?: number;
|
||||
status?: 'Active' | 'Expired' | 'Disabled' | 'Maxed';
|
||||
|
||||
static filters: FiltersMap = {
|
||||
expenseAccount: () => ({
|
||||
rootType: AccountRootTypeEnum.Expense,
|
||||
isGroup: false,
|
||||
}),
|
||||
validations: ValidationMap = {
|
||||
used: (value: DocValue) => {
|
||||
const used = value as number;
|
||||
const maximumUse = this.maximumUse as number;
|
||||
|
||||
if (used < 0) {
|
||||
throw new ValidationError('Used count cannot be negative');
|
||||
}
|
||||
|
||||
if (maximumUse > 0 && used > maximumUse) {
|
||||
throw new ValidationError('Used count cannot exceed maximum use limit');
|
||||
}
|
||||
},
|
||||
maximumUse: (value: DocValue) => {
|
||||
const maximumUse = value as number;
|
||||
|
||||
if (maximumUse < 0) {
|
||||
throw new ValidationError('Maximum use cannot be negative');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
static getListViewSettings(): ListViewSettings {
|
||||
return {
|
||||
columns: ['name', 'fromDate', 'toDate', 'expiryDuration'],
|
||||
columns: ['name', getLoyaltyProgramStatusColumn(), 'fromDate', 'toDate'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { Money } from 'pesa';
|
||||
import { PartyRole } from './types';
|
||||
import { ModelNameEnum } from 'models/types';
|
||||
import { isLoyaltyProgramExpiredAndMaxed } from 'models/helpers';
|
||||
|
||||
export class Party extends Doc {
|
||||
role?: PartyRole;
|
||||
@@ -66,6 +67,17 @@ export class Party extends Doc {
|
||||
}
|
||||
|
||||
async _getTotalLoyaltyPoints() {
|
||||
const loyaltyProgramName = this.loyaltyProgram as string;
|
||||
if (loyaltyProgramName) {
|
||||
const isExpiredAndMaxed = await isLoyaltyProgramExpiredAndMaxed(
|
||||
this.fyo,
|
||||
loyaltyProgramName
|
||||
);
|
||||
if (isExpiredAndMaxed) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
const data = (await this.fyo.db.getAll(ModelNameEnum.LoyaltyPointEntry, {
|
||||
fields: ['name', 'loyaltyPoints', 'expiryDate', 'postingDate'],
|
||||
filters: {
|
||||
|
||||
@@ -461,7 +461,10 @@ export class Payment extends Transactional {
|
||||
);
|
||||
|
||||
const previousOutstandingAmount = referenceDoc.outstandingAmount as Money;
|
||||
const outstandingAmount = previousOutstandingAmount.sub(row.amount!);
|
||||
const isReturnInvoice = (referenceDoc as Invoice).isReturn;
|
||||
const outstandingAmount = isReturnInvoice
|
||||
? previousOutstandingAmount.add(row.amount!)
|
||||
: previousOutstandingAmount.sub(row.amount!);
|
||||
await referenceDoc.setAndSync({ outstandingAmount });
|
||||
}
|
||||
}
|
||||
@@ -505,11 +508,10 @@ export class Payment extends Transactional {
|
||||
ref.referenceType!,
|
||||
ref.referenceName
|
||||
);
|
||||
|
||||
const outstandingAmount = (refDoc.outstandingAmount as Money).add(
|
||||
ref.amount!
|
||||
);
|
||||
|
||||
const isReturnInvoice = (refDoc as Invoice).isReturn;
|
||||
const outstandingAmount = isReturnInvoice
|
||||
? (refDoc.outstandingAmount as Money).sub(ref.amount!)
|
||||
: (refDoc.outstandingAmount as Money).add(ref.amount!);
|
||||
await refDoc.setAndSync({ outstandingAmount });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,44 @@ import { ModelNameEnum } from 'models/types';
|
||||
import { getInvoiceActions, getTransactionStatusColumn } from '../../helpers';
|
||||
import { Invoice } from '../Invoice/Invoice';
|
||||
import { PurchaseInvoiceItem } from '../PurchaseInvoiceItem/PurchaseInvoiceItem';
|
||||
import { createBatch } from 'models/inventory/helpers';
|
||||
|
||||
export class PurchaseInvoice extends Invoice {
|
||||
items?: PurchaseInvoiceItem[];
|
||||
|
||||
async beforeSubmit(): Promise<void> {
|
||||
await super.beforeSubmit();
|
||||
|
||||
if (this.isReturn) {
|
||||
return;
|
||||
}
|
||||
|
||||
const batchesToCreate: { item: string; batch: string }[] = [];
|
||||
|
||||
for (const item of this.items ?? []) {
|
||||
if (!item.item || !item.batch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasBatch = await this.fyo.getValue(
|
||||
ModelNameEnum.Item,
|
||||
item.item,
|
||||
'hasBatch'
|
||||
);
|
||||
|
||||
if (hasBatch) {
|
||||
batchesToCreate.push({
|
||||
item: item.item,
|
||||
batch: item.batch,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const { item, batch } of batchesToCreate) {
|
||||
await createBatch(this.fyo, item, batch);
|
||||
}
|
||||
}
|
||||
|
||||
async getPosting() {
|
||||
const exchangeRate = this.exchangeRate ?? 1;
|
||||
const posting: LedgerPosting = new LedgerPosting(this, this.fyo);
|
||||
|
||||
@@ -43,12 +43,12 @@ export class SalesInvoice extends Invoice {
|
||||
this.loyaltyProgram
|
||||
)) as LoyaltyProgram;
|
||||
|
||||
let totalAmount;
|
||||
let loyaltyAmount;
|
||||
|
||||
if (this.isReturn) {
|
||||
totalAmount = this.fyo.pesa(await getReturnLoyaltyPoints(this));
|
||||
loyaltyAmount = this.fyo.pesa(await getReturnLoyaltyPoints(this));
|
||||
} else {
|
||||
totalAmount = await getAddedLPWithGrandTotal(
|
||||
loyaltyAmount = await getAddedLPWithGrandTotal(
|
||||
this.fyo,
|
||||
this.loyaltyProgram as string,
|
||||
this.loyaltyPoints as number
|
||||
@@ -57,10 +57,8 @@ export class SalesInvoice extends Invoice {
|
||||
|
||||
await posting.debit(
|
||||
loyaltyProgramDoc.expenseAccount as string,
|
||||
totalAmount
|
||||
loyaltyAmount
|
||||
);
|
||||
|
||||
await posting.credit(this.account!, totalAmount);
|
||||
}
|
||||
|
||||
if (this.taxes) {
|
||||
@@ -115,6 +113,13 @@ export class SalesInvoice extends Invoice {
|
||||
ModelNameEnum.LoyaltyProgram,
|
||||
this.loyaltyProgram
|
||||
)) as LoyaltyProgram;
|
||||
const toDate = loyaltyProgramDoc?.toDate as Date;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (toDate && new Date(toDate).getTime() < today.getTime()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this?.grandTotal) {
|
||||
return;
|
||||
|
||||
@@ -27,10 +27,17 @@ const partyData = {
|
||||
email: 'john@whoe.com',
|
||||
};
|
||||
|
||||
const today = new Date();
|
||||
const fromDate = new Date(today);
|
||||
fromDate.setDate(today.getDate() - 10);
|
||||
|
||||
const toDate = new Date(today);
|
||||
toDate.setDate(today.getDate() + 20);
|
||||
|
||||
const loyaltyProgramData = {
|
||||
name: 'program',
|
||||
fromDate: new Date('12/10/2024'),
|
||||
toDate: new Date('12/30/2024'),
|
||||
fromDate: fromDate,
|
||||
toDate: toDate,
|
||||
email: 'sample@gmail.com',
|
||||
mobile: '1234567890',
|
||||
expenseAccount: accountData.name,
|
||||
@@ -118,11 +125,11 @@ async function loyaltyPointEntryDoc(sinvName: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createSalesInvoice() {
|
||||
async function createSalesInvoice(invoiceDate?: Date) {
|
||||
const sinvDoc = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, {
|
||||
account: 'Debtors',
|
||||
party: partyData.name,
|
||||
date: new Date('12/11/2024'),
|
||||
date: invoiceDate || new Date(),
|
||||
items: [
|
||||
{
|
||||
item: itemData.name,
|
||||
@@ -189,10 +196,10 @@ test('create Sales Invoice and verify loyalty points are created correctly', asy
|
||||
});
|
||||
|
||||
test('create SINV with future date and verify loyalty points are not created', async (t) => {
|
||||
const futureDate = new Date(new Date().setDate(new Date().getDate() + 20));
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 30);
|
||||
|
||||
const sinvDoc = await createSalesInvoice();
|
||||
sinvDoc.date = futureDate;
|
||||
const sinvDoc = await createSalesInvoice(futureDate);
|
||||
|
||||
await sinvDoc.sync();
|
||||
await sinvDoc.submit();
|
||||
|
||||
+176
-10
@@ -48,7 +48,10 @@ import {
|
||||
getStockLedgerEntries,
|
||||
} from 'reports/inventory/helpers';
|
||||
import { LoyaltyPointEntry } from './baseModels/LoyaltyPointEntry/LoyaltyPointEntry';
|
||||
import { generateSerialNumbersForItem } from './inventory/helpers';
|
||||
import {
|
||||
generateSerialNumbersForItem,
|
||||
generateBatchForItem,
|
||||
} from './inventory/helpers';
|
||||
|
||||
export function getQuoteActions(
|
||||
fyo: Fyo,
|
||||
@@ -116,6 +119,11 @@ export async function getItemQtyMap(doc: SalesInvoice): Promise<ItemQtyMap> {
|
||||
|
||||
export async function getItemVisibility(fyo: Fyo): Promise<ItemVisibility> {
|
||||
const posProfileName = fyo.singles.POSSettings?.posProfile as string;
|
||||
const enableERPNextSync = fyo.singles.AccountingSettings?.enableERPNextSync;
|
||||
|
||||
if (enableERPNextSync) {
|
||||
return fyo.singles.POSSettings?.itemVisibilityERP as ItemVisibility;
|
||||
}
|
||||
|
||||
if (posProfileName) {
|
||||
const posProfile = await fyo.doc.getDoc(
|
||||
@@ -738,6 +746,74 @@ export function getDocStatusListColumn(): ColumnConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export function getLoyaltyProgramStatusColumn(): ColumnConfig {
|
||||
return {
|
||||
label: t`Status`,
|
||||
fieldname: 'status',
|
||||
fieldtype: 'Select',
|
||||
render(doc) {
|
||||
const status = getLoyaltyProgramStatus(doc);
|
||||
const color = loyaltyProgramStatusColor[status] ?? 'gray';
|
||||
const label = getLoyaltyProgramStatusText(status);
|
||||
|
||||
return {
|
||||
template: `<Badge class="text-xs" color="${color}">${label}</Badge>`,
|
||||
metadata: {
|
||||
status,
|
||||
color,
|
||||
label,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getLoyaltyProgramStatus(doc?: RenderData | Doc): string {
|
||||
if (!doc) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const currentDate = new Date();
|
||||
currentDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const toDate = doc.toDate as Date;
|
||||
|
||||
if (toDate && toDate <= currentDate) {
|
||||
return 'Expired';
|
||||
}
|
||||
|
||||
const maximumUse = doc.maximumUse as number;
|
||||
const used = doc.used as number;
|
||||
|
||||
if (maximumUse > 0 && used >= maximumUse) {
|
||||
return 'Maxed';
|
||||
}
|
||||
|
||||
return 'Active';
|
||||
}
|
||||
|
||||
export const loyaltyProgramStatusColor: Record<string, string | undefined> = {
|
||||
Active: 'green',
|
||||
Disabled: 'gray',
|
||||
Expired: 'red',
|
||||
Maxed: 'orange',
|
||||
};
|
||||
|
||||
export function getLoyaltyProgramStatusText(status: string): string {
|
||||
switch (status) {
|
||||
case 'Active':
|
||||
return t`Active`;
|
||||
case 'Disabled':
|
||||
return t`Disabled`;
|
||||
case 'Expired':
|
||||
return t`Expired`;
|
||||
case 'Maxed':
|
||||
return t`Maxed`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
type ModelsWithItems = Invoice | StockTransfer | StockMovement;
|
||||
export async function addItem<M extends ModelsWithItems>(name: string, doc: M) {
|
||||
if (!doc.canEdit) {
|
||||
@@ -761,6 +837,13 @@ export async function addItem<M extends ModelsWithItems>(name: string, doc: M) {
|
||||
|
||||
await item.set('item', name);
|
||||
|
||||
if (doc instanceof Invoice && !doc.isSales) {
|
||||
const batchName = await generateBatchForItem(doc.fyo, name);
|
||||
if (batchName) {
|
||||
await item.set('batch', batchName);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
doc instanceof StockTransfer &&
|
||||
doc.schemaName === ModelNameEnum.PurchaseReceipt
|
||||
@@ -883,6 +966,15 @@ export async function createLoyaltyPointEntry(doc: Invoice) {
|
||||
if (!loyaltyProgramDoc.isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toDate = loyaltyProgramDoc.toDate as Date;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (toDate && new Date(toDate).getTime() < today.getTime()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expiryDate = new Date(Date.now());
|
||||
|
||||
expiryDate.setDate(
|
||||
@@ -951,18 +1043,22 @@ export function getLoyaltyProgramTier(
|
||||
let loyaltyProgramTier: CollectionRulesItems | undefined;
|
||||
|
||||
for (const row of loyaltyProgramData.collectionRules) {
|
||||
if (isPesa(row.minimumTotalSpent)) {
|
||||
const minimumSpent = row.minimumTotalSpent;
|
||||
if (row.minimumTotalSpent !== undefined && row.minimumTotalSpent !== null) {
|
||||
let minimumSpent: Money;
|
||||
|
||||
if (!minimumSpent.lte(grandTotal)) {
|
||||
continue;
|
||||
if (isPesa(row.minimumTotalSpent)) {
|
||||
minimumSpent = row.minimumTotalSpent;
|
||||
} else {
|
||||
minimumSpent = new Money(row.minimumTotalSpent as number);
|
||||
}
|
||||
|
||||
if (
|
||||
!loyaltyProgramTier ||
|
||||
minimumSpent.gt(loyaltyProgramTier.minimumTotalSpent as Money)
|
||||
) {
|
||||
loyaltyProgramTier = row;
|
||||
if (minimumSpent.lte(grandTotal)) {
|
||||
if (
|
||||
!loyaltyProgramTier ||
|
||||
minimumSpent.gt(loyaltyProgramTier.minimumTotalSpent as Money)
|
||||
) {
|
||||
loyaltyProgramTier = row;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1537,6 +1633,43 @@ export async function validateCouponCode(
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateLoyaltyProgram(
|
||||
doc: Invoice,
|
||||
loyaltyProgramName: string
|
||||
) {
|
||||
const loyaltyProgram = await doc.fyo.db.getAll(ModelNameEnum.LoyaltyProgram, {
|
||||
fields: ['fromDate', 'toDate', 'maximumUse', 'used', 'isEnabled'],
|
||||
filters: { name: loyaltyProgramName },
|
||||
});
|
||||
|
||||
if (
|
||||
(loyaltyProgram[0]?.maximumUse as number) > 0 &&
|
||||
(loyaltyProgram[0]?.used as number) >=
|
||||
(loyaltyProgram[0]?.maximumUse as number)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
loyaltyProgram[0].fromDate &&
|
||||
(doc.date as Date) < (loyaltyProgram[0].fromDate as Date)
|
||||
) {
|
||||
throw new ValidationError('Loyalty program is not yet active');
|
||||
}
|
||||
|
||||
const toDate = loyaltyProgram[0].toDate as Date;
|
||||
if (toDate) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const normalizedToDate = new Date(toDate);
|
||||
normalizedToDate.setHours(0, 0, 0, 0);
|
||||
|
||||
if (normalizedToDate.getTime() < today.getTime()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function removeFreeItems(sinvDoc: SalesInvoice) {
|
||||
if (!sinvDoc || !sinvDoc.items) {
|
||||
return;
|
||||
@@ -1611,3 +1744,36 @@ export function roundFreeItemQty(
|
||||
): number {
|
||||
return Math[roundingMethod](quantity);
|
||||
}
|
||||
|
||||
export async function isLoyaltyProgramExpiredAndMaxed(
|
||||
fyo: Fyo,
|
||||
loyaltyProgramName: string
|
||||
): Promise<boolean> {
|
||||
if (!loyaltyProgramName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const loyaltyProgram = await fyo.db.getAll(ModelNameEnum.LoyaltyProgram, {
|
||||
fields: ['toDate', 'maximumUse', 'used', 'isEnabled'],
|
||||
filters: { name: loyaltyProgramName },
|
||||
});
|
||||
|
||||
if (!loyaltyProgram.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const program = loyaltyProgram[0];
|
||||
const currentDate = new Date();
|
||||
currentDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const toDate = program.toDate as Date;
|
||||
const isExpired =
|
||||
toDate && new Date(toDate).getTime() < currentDate.getTime();
|
||||
|
||||
const maximumUse = (program.maximumUse as number) || 0;
|
||||
const used = (program.used as number) || 0;
|
||||
const isMaxed = maximumUse > 0 && used >= maximumUse;
|
||||
|
||||
const result = isExpired || isMaxed;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export class POSSettings extends Doc {
|
||||
itemWeightDigits?: number;
|
||||
defaultAccount?: string;
|
||||
itemVisibility?: string;
|
||||
itemVisibilityERP?: 'ERP Sync Items';
|
||||
posUI?: 'Classic' | 'Modern';
|
||||
canChangeRate?: boolean;
|
||||
canEditDiscount?: boolean;
|
||||
@@ -46,6 +47,9 @@ export class POSSettings extends Doc {
|
||||
!this.fyo.singles.InventorySettings?.enableBarcodes ||
|
||||
!this.weightEnabledBarcode,
|
||||
itemVisibility: () =>
|
||||
!this.fyo.singles.AccountingSettings?.enablePointOfSaleWithOutInventory,
|
||||
!this.fyo.singles.AccountingSettings?.enablePointOfSaleWithOutInventory ||
|
||||
!!this.fyo.singles.AccountingSettings?.enableERPNextSync,
|
||||
itemVisibilityERP: () =>
|
||||
!this.fyo.singles.AccountingSettings?.enableERPNextSync,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import { StockMovementItem } from './StockMovementItem';
|
||||
import { Transfer } from './Transfer';
|
||||
import {
|
||||
canValidateSerialNumber,
|
||||
createBatch,
|
||||
generateBatchForItem,
|
||||
getSerialNumberFromDoc,
|
||||
updateSerialNumbers,
|
||||
validateBatch,
|
||||
@@ -65,6 +67,42 @@ export class StockMovement extends Transfer {
|
||||
await updateSerialNumbers(this, false);
|
||||
}
|
||||
|
||||
async beforeSubmit(): Promise<void> {
|
||||
await super.beforeSubmit();
|
||||
|
||||
const batchesToCreate: { item: string; batch: string }[] = [];
|
||||
|
||||
for (const item of this.items ?? []) {
|
||||
if (!item.item || !item.batch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasBatch = await this.fyo.getValue(
|
||||
ModelNameEnum.Item,
|
||||
item.item,
|
||||
'hasBatch'
|
||||
);
|
||||
|
||||
if (hasBatch) {
|
||||
const batchExists = await this.fyo.db.exists(
|
||||
ModelNameEnum.Batch,
|
||||
item.batch
|
||||
);
|
||||
|
||||
if (!batchExists) {
|
||||
batchesToCreate.push({
|
||||
item: item.item,
|
||||
batch: item.batch,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const { item, batch } of batchesToCreate) {
|
||||
await createBatch(this.fyo, item, batch);
|
||||
}
|
||||
}
|
||||
|
||||
async afterCancel(): Promise<void> {
|
||||
await super.afterCancel();
|
||||
await updateSerialNumbers(this, true);
|
||||
@@ -125,10 +163,10 @@ export class StockMovement extends Transfer {
|
||||
item: row.item!,
|
||||
rate: row.rate!,
|
||||
quantity: row.quantity!,
|
||||
batch: row.batch!,
|
||||
serialNumber: row.serialNumber!,
|
||||
fromLocation: row.fromLocation,
|
||||
toLocation: row.toLocation,
|
||||
batch: row.batch ?? undefined,
|
||||
serialNumber: row.serialNumber ?? undefined,
|
||||
fromLocation: row.fromLocation ?? undefined,
|
||||
toLocation: row.toLocation ?? undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -142,19 +180,30 @@ export class StockMovement extends Transfer {
|
||||
throw new ValidationError(t`Item ${name} not found`);
|
||||
}
|
||||
|
||||
let batch: string | null | undefined =
|
||||
(itemDoc.defaultBatch as string | null | undefined) ?? null;
|
||||
|
||||
if (
|
||||
this.movementType === MovementTypeEnum.MaterialReceipt &&
|
||||
itemDoc.hasBatch &&
|
||||
!batch
|
||||
) {
|
||||
batch = await generateBatchForItem(this.fyo, name);
|
||||
}
|
||||
|
||||
const item = {
|
||||
name: itemDoc.name,
|
||||
batch: itemDoc.defaultBatch ?? null,
|
||||
batch,
|
||||
};
|
||||
|
||||
if (item.batch) {
|
||||
const batchDoc = await this.fyo.doc.getDoc(
|
||||
ModelNameEnum.Batch,
|
||||
item.batch as string
|
||||
item.batch
|
||||
);
|
||||
if (batchDoc && batchDoc.item !== name) {
|
||||
throw new ValidationError(
|
||||
t`Batch ${item.batch as string} does not belong to Item ${name}`
|
||||
t`Batch ${item.batch} does not belong to Item ${name}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ValidationError } from 'fyo/utils/errors';
|
||||
import { ModelNameEnum } from 'models/types';
|
||||
import { Money } from 'pesa';
|
||||
import { safeParseFloat } from 'utils/index';
|
||||
import { generateSerialNumbersForItem } from './helpers';
|
||||
import { generateSerialNumbersForItem, getSuggestedBatchName } from './helpers';
|
||||
import { StockMovement } from './StockMovement';
|
||||
import { TransferItem } from './TransferItem';
|
||||
import { MovementTypeEnum } from './types';
|
||||
@@ -80,14 +80,43 @@ export class StockMovementItem extends TransferItem {
|
||||
};
|
||||
},
|
||||
batch: async (doc: Doc) => {
|
||||
let suggestedBatch: string | undefined;
|
||||
let hasBatch = false;
|
||||
|
||||
if (doc.parentdoc?.movementType === MovementTypeEnum.MaterialReceipt) {
|
||||
hasBatch = !!(await doc.fyo.getValue(
|
||||
ModelNameEnum.Item,
|
||||
doc.item as string,
|
||||
'hasBatch'
|
||||
));
|
||||
|
||||
if (hasBatch) {
|
||||
suggestedBatch = await getSuggestedBatchName(
|
||||
doc.fyo,
|
||||
doc.item as string
|
||||
);
|
||||
|
||||
if (suggestedBatch) {
|
||||
await doc.set('batch', suggestedBatch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const batches = await doc.fyo.db.getAll(ModelNameEnum.Batch, {
|
||||
fields: ['name'],
|
||||
filters: { item: doc.item as string },
|
||||
});
|
||||
const batchName = batches.map((b) => b.name) as string[];
|
||||
const existingBatchNames = batches.map((b) => b.name) as string[];
|
||||
|
||||
const allBatches = new Set<string>(existingBatchNames);
|
||||
if (suggestedBatch) {
|
||||
allBatches.add(suggestedBatch);
|
||||
}
|
||||
|
||||
const finalBatchList = Array.from(allBatches);
|
||||
|
||||
return {
|
||||
name: ['in', batchName],
|
||||
name: ['in', finalBatchList],
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -338,6 +367,24 @@ export class StockMovementItem extends TransferItem {
|
||||
if (ch.changed === 'item') {
|
||||
await this.set('serialNumber', '');
|
||||
|
||||
if (
|
||||
this.parentdoc?.movementType === MovementTypeEnum.MaterialReceipt &&
|
||||
this.item
|
||||
) {
|
||||
const hasBatch = await this.fyo.getValue(
|
||||
ModelNameEnum.Item,
|
||||
this.item,
|
||||
'hasBatch'
|
||||
);
|
||||
|
||||
if (hasBatch) {
|
||||
const batchName = await getSuggestedBatchName(this.fyo, this.item);
|
||||
if (batchName) {
|
||||
await this.set('batch', batchName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldGenerateSerialNumbers) {
|
||||
await this.generateAndSetSerialNumbers();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { StockTransferItem } from './StockTransferItem';
|
||||
import { Transfer } from './Transfer';
|
||||
import { TransferItem } from './TransferItem';
|
||||
import type { SerialNumberStatus } from './types';
|
||||
import BatchSeries from 'fyo/models/BatchSeries';
|
||||
import SerialNumberSeries from 'fyo/models/SerialNumberSeries';
|
||||
|
||||
export async function validateBatch(
|
||||
@@ -19,6 +20,35 @@ export async function validateBatch(
|
||||
if (doc.schemaName === ModelNameEnum.SalesQuote) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
doc.schemaName === ModelNameEnum.PurchaseInvoice ||
|
||||
doc.schemaName === ModelNameEnum.PurchaseReceipt ||
|
||||
doc.schemaName === ModelNameEnum.StockMovement ||
|
||||
doc.schemaName === ModelNameEnum.Shipment
|
||||
) {
|
||||
for (const row of doc.items ?? []) {
|
||||
if (row.item && row.batch) {
|
||||
const hasBatch = await doc.fyo.getValue(
|
||||
ModelNameEnum.Item,
|
||||
row.item,
|
||||
'hasBatch'
|
||||
);
|
||||
|
||||
if (hasBatch) {
|
||||
const batchExists = await doc.fyo.db.exists(
|
||||
ModelNameEnum.Batch,
|
||||
row.batch
|
||||
);
|
||||
|
||||
if (!batchExists) {
|
||||
await createBatch(doc.fyo, row.item, row.batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of doc.items ?? []) {
|
||||
await validateItemRowBatch(row);
|
||||
}
|
||||
@@ -531,3 +561,137 @@ export async function getExistingActiveSerialNumbersForItem(
|
||||
|
||||
return selectedSerialNumbers.join('\n');
|
||||
}
|
||||
|
||||
export async function getSuggestedBatchName(
|
||||
fyo: Fyo,
|
||||
itemName: string
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const batchSeries = await fyo.getValue(
|
||||
ModelNameEnum.Item,
|
||||
itemName,
|
||||
'batchSeries'
|
||||
);
|
||||
|
||||
if (!batchSeries) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const seriesName = (batchSeries as string).trim();
|
||||
|
||||
const seriesExists = await fyo.db.exists('BatchSeries', seriesName);
|
||||
|
||||
if (!seriesExists) {
|
||||
await fyo.doc
|
||||
.getNewDoc('BatchSeries', {
|
||||
name: seriesName,
|
||||
start: 1001,
|
||||
padZeros: 4,
|
||||
current: 1001,
|
||||
})
|
||||
.sync();
|
||||
}
|
||||
|
||||
const batchSeriesDoc = (await fyo.doc.getDoc(
|
||||
'BatchSeries',
|
||||
seriesName
|
||||
)) as BatchSeries;
|
||||
|
||||
const padZeros = (batchSeriesDoc.padZeros as number) ?? 4;
|
||||
|
||||
const existingBatches = (await fyo.db.getAllRaw(ModelNameEnum.Batch, {
|
||||
fields: ['name'],
|
||||
filters: { item: itemName },
|
||||
})) as { name: string }[];
|
||||
|
||||
let nextNumber: number;
|
||||
|
||||
if (existingBatches && existingBatches.length > 0) {
|
||||
let highestNumber = -1;
|
||||
|
||||
for (const batch of existingBatches) {
|
||||
const batchName = batch.name;
|
||||
// Extract numeric part from batch name (handles names like "com-1001")
|
||||
const numericPart = batchName.replace(seriesName, '');
|
||||
const num = parseInt(numericPart, 10);
|
||||
|
||||
if (!isNaN(num) && num > highestNumber) {
|
||||
highestNumber = num;
|
||||
}
|
||||
}
|
||||
|
||||
if (highestNumber >= 0) {
|
||||
nextNumber = highestNumber + 1;
|
||||
} else {
|
||||
nextNumber = (batchSeriesDoc.start as number) ?? 1001;
|
||||
}
|
||||
} else {
|
||||
nextNumber = (batchSeriesDoc.start as number) ?? 1001;
|
||||
}
|
||||
|
||||
const batchName = `${seriesName}${nextNumber
|
||||
.toString()
|
||||
.padStart(padZeros, '0')}`;
|
||||
|
||||
return batchName;
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBatch(
|
||||
fyo: Fyo,
|
||||
itemName: string,
|
||||
batchName: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const batchExists = await fyo.db.exists(ModelNameEnum.Batch, batchName);
|
||||
if (batchExists) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const batchDoc = fyo.doc.getNewDoc('Batch', {
|
||||
name: batchName,
|
||||
item: itemName,
|
||||
});
|
||||
|
||||
await batchDoc.sync();
|
||||
|
||||
const batchSeries = await fyo.getValue(
|
||||
ModelNameEnum.Item,
|
||||
itemName,
|
||||
'batchSeries'
|
||||
);
|
||||
|
||||
if (batchSeries) {
|
||||
const seriesName = (batchSeries as string).trim();
|
||||
const batchSeriesDoc = (await fyo.doc.getDoc(
|
||||
'BatchSeries',
|
||||
seriesName
|
||||
)) as BatchSeries;
|
||||
|
||||
const num = parseInt(batchName, 10);
|
||||
if (!isNaN(num)) {
|
||||
await batchSeriesDoc.set('current', num);
|
||||
await batchSeriesDoc.sync();
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateBatchForItem(
|
||||
fyo: Fyo,
|
||||
itemName: string
|
||||
): Promise<string | undefined> {
|
||||
const batchName = await getSuggestedBatchName(fyo, itemName);
|
||||
if (!batchName) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const success = await createBatch(fyo, itemName, batchName);
|
||||
return success ? batchName : undefined;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export enum ModelNameEnum {
|
||||
AccountingSettings = 'AccountingSettings',
|
||||
Address = 'Address',
|
||||
Batch = 'Batch',
|
||||
BatchSeries = 'BatchSeries',
|
||||
Color = 'Color',
|
||||
Currency = 'Currency',
|
||||
GetStarted = 'GetStarted',
|
||||
|
||||
Reference in New Issue
Block a user