import { t } from 'fyo'; import { DocValue } from 'fyo/core/types'; import { ChangeArg, FiltersMap, FormulaMap, HiddenMap, ReadOnlyMap, RequiredMap, ValidationMap, } from 'fyo/model/types'; import { ValidationError } from 'fyo/utils/errors'; import { ModelNameEnum } from 'models/types'; import { Money } from 'pesa'; import { safeParseFloat } from 'utils/index'; import { generateSerialNumbersForItem, getSuggestedBatchName } from './helpers'; import { StockMovement } from './StockMovement'; import { TransferItem } from './TransferItem'; import { MovementTypeEnum } from './types'; import { Doc } from 'fyo/model/doc'; export class StockMovementItem extends TransferItem { name?: string; item?: string; fromLocation?: string; toLocation?: string; unit?: string; transferUnit?: string; quantity?: number; transferQuantity?: number; unitConversionFactor?: number; rate?: Money; amount?: Money; batch?: string; serialNumber?: string; parentdoc?: StockMovement; get isIssue() { return this.parentdoc?.movementType === MovementTypeEnum.MaterialIssue; } get isReceipt() { return this.parentdoc?.movementType === MovementTypeEnum.MaterialReceipt; } get isTransfer() { return this.parentdoc?.movementType === MovementTypeEnum.MaterialTransfer; } get isManufacture() { return this.parentdoc?.movementType === MovementTypeEnum.Manufacture; } static filters: FiltersMap = { item: () => ({ trackItem: true }), transferUnit: async (doc: Doc) => { const conversionItems = await doc.fyo.db.getAll( ModelNameEnum.UOMConversionItem, { fields: ['uom'], filters: { parent: doc.item as string }, } ); const conversionUoms = conversionItems.map((i) => i.uom) as string[]; const baseUnit = await doc.fyo.getValue( ModelNameEnum.Item, doc.item as string, 'unit' ); const validUoms = [...conversionUoms, baseUnit].filter( Boolean ) as string[]; return { name: ['in', validUoms], }; }, 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 existingBatchNames = batches.map((b) => b.name) as string[]; const allBatches = new Set(existingBatchNames); if (suggestedBatch) { allBatches.add(suggestedBatch); } const finalBatchList = Array.from(allBatches); return { name: ['in', finalBatchList], }; }, }; async validate() { await super.validate(); await this.validateBatchAndItemConsistency(); } async validateBatchAndItemConsistency() { if (!this.batch || !this.item) { return; } const batchDoc = await this.fyo.doc.getDoc(ModelNameEnum.Batch, this.batch); if (!batchDoc) { return; } if (batchDoc.item !== this.item) { throw new ValidationError( t`Batch ${this.batch} does not belong to Item ${this.item}` ); } } formulas: FormulaMap = { rate: { formula: async () => { if (!this.item) { return this.rate; } return await this.fyo.getValue(ModelNameEnum.Item, this.item, 'rate'); }, dependsOn: ['item'], }, amount: { formula: () => this.rate!.mul(this.quantity!), dependsOn: ['item', 'rate', 'quantity'], }, fromLocation: { formula: () => { if (this.isReceipt || this.isTransfer || this.isManufacture) { return null; } const defaultLocation = this.fyo.singles.InventorySettings?.defaultLocation; if (defaultLocation && !this.fromLocation && this.isIssue) { return defaultLocation; } return this.toLocation; }, dependsOn: ['movementType'], }, toLocation: { formula: () => { if (this.isIssue || this.isTransfer || this.isManufacture) { return null; } const defaultLocation = this.fyo.singles.InventorySettings?.defaultLocation; if (defaultLocation && !this.toLocation && this.isReceipt) { return defaultLocation; } return this.toLocation; }, dependsOn: ['movementType'], }, unit: { formula: async () => (await this.fyo.getValue( 'Item', this.item as string, 'unit' )) as string, dependsOn: ['item'], }, transferUnit: { formula: async (fieldname) => { if (fieldname === 'quantity' || fieldname === 'unit') { return this.unit; } return (await this.fyo.getValue( 'Item', this.item as string, 'unit' )) as string; }, dependsOn: ['item', 'unit'], }, transferQuantity: { formula: (fieldname) => { if (fieldname === 'quantity' || this.unit === this.transferUnit) { return this.quantity; } return this.transferQuantity; }, dependsOn: ['item', 'quantity'], }, quantity: { formula: async (fieldname) => { if (!this.item) { return this.quantity as number; } const itemDoc = await this.fyo.doc.getDoc( ModelNameEnum.Item, this.item ); const unitDoc = itemDoc.getLink('uom'); let quantity: number = this.quantity ?? 1; if (fieldname === 'transferQuantity') { quantity = this.transferQuantity! * this.unitConversionFactor!; } if (unitDoc?.isWhole) { return Math.round(quantity); } return safeParseFloat(quantity); }, dependsOn: [ 'quantity', 'transferQuantity', 'transferUnit', 'unitConversionFactor', ], }, unitConversionFactor: { formula: async () => { if (this.unit === this.transferUnit) { return 1; } const conversionFactor = await this.fyo.db.getAll( ModelNameEnum.UOMConversionItem, { fields: ['conversionFactor'], filters: { parent: this.item! }, } ); return safeParseFloat(conversionFactor[0]?.conversionFactor ?? 1); }, dependsOn: ['transferUnit'], }, }; validations: ValidationMap = { fromLocation: (value) => { if (!this.isManufacture) { return; } if (value && this.toLocation) { throw new ValidationError( this.fyo.t`Only From or To can be set for Manufacture` ); } }, toLocation: (value) => { if (!this.isManufacture) { return; } if (value && this.fromLocation) { throw new ValidationError( this.fyo.t`Only From or To can be set for Manufacture` ); } }, batch: async () => { if (!this.item || !this.batch) return; const batchDoc = await this.fyo.doc.getDoc( ModelNameEnum.Batch, this.batch ); if (!batchDoc) return; if (batchDoc.item !== this.item) { throw new ValidationError( t`Batch ${this.batch} does not belong to Item ${this.item}` ); } }, transferUnit: async (value: DocValue) => { if (!this.item) { return; } const item = await this.fyo.db.getAll(ModelNameEnum.UOMConversionItem, { fields: ['parent'], filters: { uom: value as string, parent: this.item }, }); if (item.length < 1) throw new ValidationError( t`Transfer Unit ${value as string} is not applicable for Item ${ this.item }` ); }, }; required: RequiredMap = { fromLocation: () => this.isIssue || this.isTransfer, toLocation: () => this.isReceipt || this.isTransfer, }; readOnly: ReadOnlyMap = { fromLocation: () => this.isReceipt, toLocation: () => this.isIssue, }; override hidden: HiddenMap = { batch: () => !this.fyo.singles.InventorySettings?.enableBatches, serialNumber: () => !this.fyo.singles.InventorySettings?.enableSerialNumber, transferUnit: () => !this.fyo.singles.InventorySettings?.enableUomConversions, transferQuantity: () => !this.fyo.singles.InventorySettings?.enableUomConversions, unitConversionFactor: () => !this.fyo.singles.InventorySettings?.enableUomConversions, }; static createFilters: FiltersMap = { item: () => ({ trackItem: true, itemType: 'Product' }), }; override async change(ch: ChangeArg): Promise { await super.change(ch); const shouldGenerateSerialNumbers = this.parentdoc?.movementType === MovementTypeEnum.MaterialReceipt && this.item && this.quantity && this.quantity > 0; 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(); } } if (ch.changed === 'quantity') { if (!this.quantity || this.quantity <= 0) { await this.set('serialNumber', ''); } else if (shouldGenerateSerialNumbers) { await this.generateAndSetSerialNumbers(); } } } private async generateAndSetSerialNumbers(): Promise { if (!this.item || !this.quantity) { return; } const serialNumbers = await generateSerialNumbersForItem( this.fyo, this.item, Math.abs(this.quantity) ); if (serialNumbers) { await this.set('serialNumber', serialNumbers); } } }