diff --git a/models/baseModels/Invoice/Invoice.ts b/models/baseModels/Invoice/Invoice.ts index 8ea493cd..0b4bb73e 100644 --- a/models/baseModels/Invoice/Invoice.ts +++ b/models/baseModels/Invoice/Invoice.ts @@ -18,7 +18,6 @@ import { } from 'models/helpers'; import { InventorySettings } from 'models/inventory/InventorySettings'; import { StockTransfer } from 'models/inventory/StockTransfer'; -import { getStockTransfer } from 'models/inventory/tests/helpers'; import { Transactional } from 'models/Transactional/Transactional'; import { ModelNameEnum } from 'models/types'; import { Money } from 'pesa'; @@ -74,6 +73,12 @@ export abstract class Invoice extends Transactional { return this.fyo.singles.SystemSettings?.currency ?? DEFAULT_CURRENCY; } + get stockTransferSchemaName() { + return this.isSales + ? ModelNameEnum.Shipment + : ModelNameEnum.PurchaseReceipt; + } + constructor(schema: Schema, data: DocValueMap, fyo: Fyo) { super(schema, data, fyo); this._setGetCurrencies(); @@ -487,9 +492,7 @@ export abstract class Invoice extends Transactional { return null; } - const schemaName = this.isSales - ? ModelNameEnum.Shipment - : ModelNameEnum.PurchaseReceipt; + const schemaName = this.stockTransferSchemaName; const defaults = (this.fyo.singles.Defaults as Defaults) ?? {}; let terms; @@ -505,7 +508,6 @@ export abstract class Invoice extends Transactional { terms, backReference: this.name, }; - console.log(data.backReference); const location = (this.fyo.singles.InventorySettings as InventorySettings) @@ -521,7 +523,11 @@ export abstract class Invoice extends Transactional { const item = row.item; const quantity = row.stockNotTransferred; const trackItem = itemDoc.trackItem; - const rate = row.rate; + let rate = row.rate as Money; + + if (this.exchangeRate && this.exchangeRate > 1) { + rate = rate.mul(this.exchangeRate); + } if (!quantity || !trackItem) { continue; @@ -541,4 +547,54 @@ export abstract class Invoice extends Transactional { return transfer; } + + async beforeCancel(): Promise { + await super.beforeCancel(); + await this._validateStockTransferCancelled(); + } + + async beforeDelete(): Promise { + await super.beforeCancel(); + await this._validateStockTransferCancelled(); + await this._deleteCancelledStockTransfers(); + } + + async _deleteCancelledStockTransfers() { + const schemaName = this.stockTransferSchemaName; + const transfers = await this._getLinkedStockTransferNames(true); + + for (const { name } of transfers) { + const st = await this.fyo.doc.getDoc(schemaName, name); + await st.delete(); + } + } + + async _validateStockTransferCancelled() { + const schemaName = this.stockTransferSchemaName; + const transfers = await this._getLinkedStockTransferNames(false); + if (!transfers?.length) { + return; + } + + const names = transfers.map(({ name }) => name).join(', '); + const label = this.fyo.schemaMap[schemaName]?.label ?? schemaName; + throw new ValidationError( + this.fyo.t`Cannot cancel ${this.schema.label} ${this + .name!} because of the following ${label}: ${names}` + ); + } + + async _getLinkedStockTransferNames(cancelled: boolean) { + const name = this.name; + if (!name) { + throw new ValidationError(`Name not found for ${this.schema.label}`); + } + + const schemaName = this.stockTransferSchemaName; + const transfers = (await this.fyo.db.getAllRaw(schemaName, { + fields: ['name'], + filters: { backReference: this.name!, cancelled }, + })) as { name: string }[]; + return transfers; + } } diff --git a/models/inventory/StockTransfer.ts b/models/inventory/StockTransfer.ts index 755874e5..00ac0df9 100644 --- a/models/inventory/StockTransfer.ts +++ b/models/inventory/StockTransfer.ts @@ -9,7 +9,6 @@ import { getLedgerLinkAction, getNumberSeries } from 'models/helpers'; import { LedgerPosting } from 'models/Transactional/LedgerPosting'; import { ModelNameEnum } from 'models/types'; import { Money } from 'pesa'; -import { getMapFromList } from 'utils/index'; import { StockTransferItem } from './StockTransferItem'; import { Transfer } from './Transfer'; @@ -176,7 +175,10 @@ export abstract class StockTransfer extends Transfer { const notTransferred = (row.stockNotTransferred as number) ?? 0; const transferred = transferMap[item]; - if (!transferred || !notTransferred) { + if ( + typeof transferred !== 'number' || + typeof notTransferred !== 'number' + ) { continue; } @@ -218,4 +220,10 @@ export abstract class StockTransfer extends Transfer { return acc; }, {} as Record); } + + override duplicate(): Doc { + const doc = super.duplicate() as StockTransfer; + doc.backReference = undefined; + return doc; + } } diff --git a/models/inventory/tests/testStockTransfer.spec.ts b/models/inventory/tests/testStockTransfer.spec.ts index c62c6c91..51cc6023 100644 --- a/models/inventory/tests/testStockTransfer.spec.ts +++ b/models/inventory/tests/testStockTransfer.spec.ts @@ -1,12 +1,14 @@ import { assertDoesNotThrow, - assertThrows + assertThrows, } from 'backend/database/tests/helpers'; +import { Invoice } from 'models/baseModels/Invoice/Invoice'; import { ModelNameEnum } from 'models/types'; import { RawValue } from 'schemas/types'; import test from 'tape'; import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers'; import { InventorySettings } from '../InventorySettings'; +import { StockTransfer } from '../StockTransfer'; import { ValuationMethod } from '../types'; import { getALEs, getItem, getSLEs, getStockTransfer } from './helpers'; @@ -285,4 +287,253 @@ test('Purchase Receipt, cancel and delete', async (t) => { 'doc deleted' ); }); + +test('Purchase Invoice then Purchase Receipt', async (t) => { + const rate = testDocs.Item[item].rate as number; + const quantity = 3; + const pinv = fyo.doc.getNewDoc(ModelNameEnum.PurchaseInvoice) as Invoice; + + const date = new Date('2022-01-04'); + await pinv.set({ + date, + party, + account: 'Creditors', + }); + await pinv.append('items', { item, quantity, rate }); + await pinv.sync(); + await pinv.submit(); + + t.equal(pinv.name, 'PINV-1001', 'PINV name matches'); + t.equal(pinv.stockNotTransferred, quantity, 'stock not transferred'); + const prec = await pinv.getStockTransfer(); + if (prec === null) { + return t.ok(false, 'prec was null'); + } + + prec.date = new Date('2022-01-05'); + t.equal( + ModelNameEnum.PurchaseReceipt, + prec.schemaName, + 'stock transfer is a PREC' + ); + t.equal(prec.backReference, pinv.name, 'back reference is set'); + t.equal(prec.items?.[0].quantity, quantity, 'PREC transfers quantity'); + + await assertDoesNotThrow(async () => await prec.sync()); + await assertDoesNotThrow(async () => await prec.submit()); + + t.equal(prec.name, 'PREC-1002', 'PREC name matches'); + t.equal(pinv.stockNotTransferred, 0, 'stock has been transferred'); + t.equal(pinv.items?.[0].stockNotTransferred, 0, 'stock has been transferred'); +}); + +test('Back Ref Purchase Receipt cancel', async (t) => { + const prec = (await fyo.doc.getDoc( + ModelNameEnum.PurchaseReceipt, + 'PREC-1002' + )) as StockTransfer; + + t.equal(prec.backReference, 'PINV-1001', 'back reference matches'); + await assertDoesNotThrow(async () => { + await prec.cancel(); + }); + + const pinv = (await fyo.doc.getDoc( + ModelNameEnum.PurchaseInvoice, + 'PINV-1001' + )) as Invoice; + + t.equal(pinv.stockNotTransferred, 3, 'pinv stock untransferred'); + t.equal( + pinv.items?.[0].stockNotTransferred, + 3, + 'pinv item stock untransferred' + ); +}); + +test('Cancel Purchase Invoice after Purchase Receipt is created', async (t) => { + const pinv = (await fyo.doc.getDoc( + ModelNameEnum.PurchaseInvoice, + 'PINV-1001' + )) as Invoice; + + const prec = await pinv.getStockTransfer(); + if (prec === null) { + return t.ok(false, 'prec was null'); + } + + prec.date = new Date('2022-01-05'); + await prec.sync(); + await prec.submit(); + + t.equal(prec.name, 'PREC-1003', 'PREC name matches'); + t.equal(prec.backReference, 'PINV-1001', 'PREC backref matches'); + + await assertThrows(async () => { + await pinv.cancel(); + }, 'cancel prevented cause of PREC'); + + const ales = await fyo.db.getAllRaw(ModelNameEnum.AccountingLedgerEntry, { + fields: ['name', 'reverted'], + filters: { referenceName: pinv.name!, reverted: true }, + }); + + t.equal(ales.length, 0); +}); + +test('Sales Invoice then partial Shipment', async (t) => { + const rate = testDocs.Item[item].rate as number; + const sinv = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice) as Invoice; + + await sinv.set({ + party, + date: new Date('2022-01-06'), + account: 'Debtors', + }); + await sinv.append('items', { item, quantity: 3, rate }); + await sinv.sync(); + await sinv.submit(); + + t.equal(sinv.name, 'SINV-1001', 'SINV name matches'); + t.equal(sinv.stockNotTransferred, 3, 'stock not transferred'); + + const shpm = await sinv.getStockTransfer(); + if (shpm === null) { + return t.ok(false, 'shpm was null'); + } + + shpm.date = new Date('2022-01-07'); + await shpm.items?.[0].set('quantity', 1); + + await assertDoesNotThrow(async () => await shpm.sync()); + await assertDoesNotThrow(async () => await shpm.submit()); + + t.equal(ModelNameEnum.Shipment, shpm.schemaName, 'stock transfer is a SHPM'); + t.equal(shpm.backReference, sinv.name, 'back reference is set'); + t.equal(shpm.items?.[0].quantity, 1, 'shpm transfers quantity 1'); + + t.equal(shpm.name, 'SHPM-1003', 'SHPM name matches'); + t.equal(sinv.stockNotTransferred, 2, 'stock qty 2 has not been transferred'); + t.equal( + sinv.items?.[0].stockNotTransferred, + 2, + 'item stock qty 2 has not been transferred' + ); +}); + +test('Sales Invoice then another Shipment', async (t) => { + const sinv = (await fyo.doc.getDoc( + ModelNameEnum.SalesInvoice, + 'SINV-1001' + )) as Invoice; + + const shpm = await sinv.getStockTransfer(); + if (shpm === null) { + return t.ok(false, 'shpm was null'); + } + + await assertDoesNotThrow(async () => await shpm.sync()); + await assertDoesNotThrow(async () => await shpm.submit()); + + t.equal(shpm.name, 'SHPM-1004', 'SHPM name matches'); + t.equal(shpm.items?.[0].quantity, 2, 'shpm transfers quantity 2'); + t.equal(sinv.stockNotTransferred, 0, 'stock has been transferred'); + t.equal( + sinv.items?.[0].stockNotTransferred, + 0, + 'item stock has been transferred' + ); + t.equal(await sinv.getStockTransfer(), null, 'no more stock transfers'); +}); + +test('Cancel Sales Invoice after Shipment is created', async (t) => { + const sinv = (await fyo.doc.getDoc( + ModelNameEnum.SalesInvoice, + 'SINV-1001' + )) as Invoice; + await assertThrows( + async () => await sinv.cancel(), + 'cancel prevent cause of SHPM' + ); + + const ales = await fyo.db.getAllRaw(ModelNameEnum.AccountingLedgerEntry, { + fields: ['name', 'reverted'], + filters: { referenceName: sinv.name!, reverted: true }, + }); + + t.equal(ales.length, 0); +}); + +test('Cancel partial Shipment', async (t) => { + let shpm = (await fyo.doc.getDoc( + ModelNameEnum.Shipment, + 'SHPM-1003' + )) as StockTransfer; + + t.equal(shpm.backReference, 'SINV-1001', 'SHPM 1 back ref is set'); + t.equal(shpm.items?.[0].quantity, 1, 'SHPM transfers qty 1'); + + await assertDoesNotThrow(async () => await shpm.cancel()); + t.ok(shpm.isCancelled, 'SHPM cancelled'); + + const sinv = (await fyo.doc.getDoc( + ModelNameEnum.SalesInvoice, + 'SINV-1001' + )) as Invoice; + t.equal(sinv.stockNotTransferred, 1, 'stock qty 1 untransferred'); + + shpm = (await fyo.doc.getDoc( + ModelNameEnum.Shipment, + 'SHPM-1004' + )) as StockTransfer; + + t.equal(shpm.backReference, 'SINV-1001', 'SHPM 2 back ref is set'); + t.equal(shpm.items?.[0].quantity, 2, 'SHPM transfers qty 2'); + + await assertDoesNotThrow(async () => await shpm.cancel()); + t.ok(shpm.isCancelled, 'SHPM cancelled'); + + t.equal(sinv.stockNotTransferred, 3, 'all stock untransferred'); +}); + +test('Duplicate Shipment, backref unset', async (t) => { + const shpm = (await fyo.doc.getDoc( + ModelNameEnum.Shipment, + 'SHPM-1003' + )) as StockTransfer; + + t.ok(shpm.backReference, 'SHPM back ref is set'); + + const doc = shpm.duplicate(); + t.notOk(doc.backReference, 'Duplicate SHPM back ref is not set'); +}); + +test('Cancel and Delete Sales Invoice with cancelled Shipments', async (t) => { + const sinv = (await fyo.doc.getDoc( + ModelNameEnum.SalesInvoice, + 'SINV-1001' + )) as Invoice; + + await assertDoesNotThrow(async () => await sinv.cancel()); + t.ok(sinv.isCancelled, 'sinv cancelled'); + + const transfers = (await fyo.db.getAllRaw(ModelNameEnum.Shipment, { + fields: ['name'], + filters: { backReference: 'SINV-1001' }, + })) as { name: string }[]; + + await assertDoesNotThrow(async () => await sinv.delete()); + t.notOk( + await fyo.db.exists(ModelNameEnum.SalesInvoice, 'SINV-1001'), + 'SINV-1001 deleted' + ); + + for (const { name } of transfers) { + t.notOk( + await fyo.db.exists(ModelNameEnum.Shipment, 'SINV-1001'), + `linked Shipment ${name} deleted` + ); + } +}); + closeTestFyo(fyo, __filename);