mirror of
https://github.com/frappe/books.git
synced 2024-12-23 03:19:01 +00:00
Merge pull request #474 from 18alantom/inventory
feat: base inventory features
This commit is contained in:
commit
78e13db385
@ -1,3 +1,4 @@
|
||||
import { ModelNameEnum } from '../../models/types';
|
||||
import DatabaseCore from './core';
|
||||
import { BespokeFunction } from './types';
|
||||
|
||||
@ -130,4 +131,35 @@ export class BespokeQueries {
|
||||
group by account
|
||||
`);
|
||||
}
|
||||
|
||||
static async getStockQuantity(
|
||||
db: DatabaseCore,
|
||||
item: string,
|
||||
location?: string,
|
||||
fromDate?: string,
|
||||
toDate?: string
|
||||
): Promise<number | null> {
|
||||
const query = db.knex!(ModelNameEnum.StockLedgerEntry)
|
||||
.sum('quantity')
|
||||
.where('item', item);
|
||||
|
||||
if (location) {
|
||||
query.andWhere('location', location);
|
||||
}
|
||||
|
||||
if (fromDate) {
|
||||
query.andWhereRaw('datetime(date) > datetime(?)', [fromDate]);
|
||||
}
|
||||
|
||||
if (toDate) {
|
||||
query.andWhereRaw('datetime(date) < datetime(?)', [toDate]);
|
||||
}
|
||||
|
||||
const value = (await query) as Record<string, number | null>[];
|
||||
if (!value.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value[0][Object.keys(value[0])[0]];
|
||||
}
|
||||
}
|
||||
|
@ -266,6 +266,12 @@ export default class DatabaseCore extends DatabaseBase {
|
||||
)) as FieldValueMap[];
|
||||
}
|
||||
|
||||
async deleteAll(schemaName: string, filters: QueryFilter): Promise<number> {
|
||||
const builder = this.knex!(schemaName);
|
||||
this.#applyFiltersToBuilder(builder, filters);
|
||||
return await builder.delete();
|
||||
}
|
||||
|
||||
async getSingleValues(
|
||||
...fieldnames: ({ fieldname: string; parent?: string } | string)[]
|
||||
): Promise<SingleValue<RawValue>> {
|
||||
@ -444,10 +450,35 @@ export default class DatabaseCore extends DatabaseBase {
|
||||
// {"date": [">=", "2017-09-09", "<=", "2017-11-01"]}
|
||||
// => `date >= 2017-09-09 and date <= 2017-11-01`
|
||||
|
||||
const filtersArray = [];
|
||||
const filtersArray = this.#getFiltersArray(filters);
|
||||
for (const i in filtersArray) {
|
||||
const filter = filtersArray[i];
|
||||
const field = filter[0] as string;
|
||||
const operator = filter[1];
|
||||
const comparisonValue = filter[2];
|
||||
const type = i === '0' ? 'where' : 'andWhere';
|
||||
|
||||
if (operator === '=') {
|
||||
builder[type](field, comparisonValue);
|
||||
} else if (
|
||||
operator === 'in' &&
|
||||
(comparisonValue as (string | null)[]).includes(null)
|
||||
) {
|
||||
const nonNulls = (comparisonValue as (string | null)[]).filter(
|
||||
Boolean
|
||||
) as string[];
|
||||
builder[type](field, operator, nonNulls).orWhere(field, null);
|
||||
} else {
|
||||
builder[type](field, operator as string, comparisonValue as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#getFiltersArray(filters: QueryFilter) {
|
||||
const filtersArray = [];
|
||||
for (const field in filters) {
|
||||
const value = filters[field];
|
||||
|
||||
let operator: string | number = '=';
|
||||
let comparisonValue = value as string | number | (string | number)[];
|
||||
|
||||
@ -477,17 +508,7 @@ export default class DatabaseCore extends DatabaseBase {
|
||||
}
|
||||
}
|
||||
|
||||
filtersArray.map((filter) => {
|
||||
const field = filter[0] as string;
|
||||
const operator = filter[1];
|
||||
const comparisonValue = filter[2];
|
||||
|
||||
if (operator === '=') {
|
||||
builder.where(field, comparisonValue);
|
||||
} else {
|
||||
builder.where(field, operator as string, comparisonValue as string);
|
||||
}
|
||||
});
|
||||
return filtersArray;
|
||||
}
|
||||
|
||||
async #getColumnDiff(schemaName: string): Promise<ColumnDiff> {
|
||||
|
@ -1,12 +1,12 @@
|
||||
import assert from 'assert';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { SchemaMap, SchemaStub, SchemaStubMap } from 'schemas/types';
|
||||
import {
|
||||
addMetaFields,
|
||||
cleanSchemas,
|
||||
getAbstractCombinedSchemas,
|
||||
} from '../../../schemas';
|
||||
import SingleValue from '../../../schemas/core/SingleValue.json';
|
||||
import { SchemaMap, SchemaStub, SchemaStubMap } from '../../../schemas/types';
|
||||
|
||||
const Customer = {
|
||||
name: 'Customer',
|
||||
@ -188,7 +188,7 @@ export async function assertThrows(
|
||||
} finally {
|
||||
if (!threw) {
|
||||
throw new assert.AssertionError({
|
||||
message: `Missing expected exception: ${message}`,
|
||||
message: `Missing expected exception${message ? `: ${message}` : ''}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -202,9 +202,9 @@ export async function assertDoesNotThrow(
|
||||
await func();
|
||||
} catch (err) {
|
||||
throw new assert.AssertionError({
|
||||
message: `Got unwanted exception: ${message}\nError: ${
|
||||
(err as Error).message
|
||||
}\n${(err as Error).stack}`,
|
||||
message: `Got unwanted exception${
|
||||
message ? `: ${message}` : ''
|
||||
}\nError: ${(err as Error).message}\n${(err as Error).stack}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -549,3 +549,78 @@ test('CRUD dependent schema', async function (t) {
|
||||
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('db deleteAll', async (t) => {
|
||||
const db = await getDb();
|
||||
|
||||
const emailOne = 'one@temp.com';
|
||||
const emailTwo = 'two@temp.com';
|
||||
const emailThree = 'three@temp.com';
|
||||
|
||||
const phoneOne = '1';
|
||||
const phoneTwo = '2';
|
||||
|
||||
const customers = [
|
||||
{ name: 'customer-a', phone: phoneOne, email: emailOne },
|
||||
{ name: 'customer-b', phone: phoneOne, email: emailOne },
|
||||
{ name: 'customer-c', phone: phoneOne, email: emailTwo },
|
||||
{ name: 'customer-d', phone: phoneOne, email: emailTwo },
|
||||
{ name: 'customer-e', phone: phoneTwo, email: emailTwo },
|
||||
{ name: 'customer-f', phone: phoneTwo, email: emailThree },
|
||||
{ name: 'customer-g', phone: phoneTwo, email: emailThree },
|
||||
];
|
||||
|
||||
for (const { name, email, phone } of customers) {
|
||||
await db.insert('Customer', {
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
...getDefaultMetaFieldValueMap(),
|
||||
});
|
||||
}
|
||||
|
||||
// Get total count
|
||||
t.equal((await db.getAll('Customer')).length, customers.length);
|
||||
|
||||
// Single filter
|
||||
t.equal(
|
||||
await db.deleteAll('Customer', { email: emailOne }),
|
||||
customers.filter((c) => c.email === emailOne).length
|
||||
);
|
||||
t.equal(
|
||||
(await db.getAll('Customer', { filters: { email: emailOne } })).length,
|
||||
0
|
||||
);
|
||||
|
||||
// Multiple filters
|
||||
t.equal(
|
||||
await db.deleteAll('Customer', { email: emailTwo, phone: phoneTwo }),
|
||||
customers.filter(
|
||||
({ phone, email }) => email === emailTwo && phone === phoneTwo
|
||||
).length
|
||||
);
|
||||
t.equal(
|
||||
await db.deleteAll('Customer', { email: emailTwo, phone: phoneTwo }),
|
||||
0
|
||||
);
|
||||
|
||||
// Includes filters
|
||||
t.equal(
|
||||
await db.deleteAll('Customer', { email: ['in', [emailTwo, emailThree]] }),
|
||||
customers.filter(
|
||||
({ email, phone }) =>
|
||||
[emailTwo, emailThree].includes(email) &&
|
||||
!(phone === phoneTwo && email === emailTwo)
|
||||
).length
|
||||
);
|
||||
t.equal(
|
||||
(
|
||||
await db.getAll('Customer', {
|
||||
filters: { email: ['in', [emailTwo, emailThree]] },
|
||||
})
|
||||
).length,
|
||||
0
|
||||
);
|
||||
|
||||
await db.close();
|
||||
});
|
||||
|
@ -46,6 +46,7 @@ export const databaseMethodSet: Set<DatabaseMethod> = new Set([
|
||||
'rename',
|
||||
'update',
|
||||
'delete',
|
||||
'deleteAll',
|
||||
'close',
|
||||
'exists',
|
||||
]);
|
||||
|
@ -1,8 +1,34 @@
|
||||
import { ModelNameEnum } from '../../models/types';
|
||||
import { defaultUOMs } from '../../utils/defaults';
|
||||
import { DatabaseManager } from '../database/manager';
|
||||
import { getDefaultMetaFieldValueMap } from '../helpers';
|
||||
|
||||
const defaultUOMs = [
|
||||
{
|
||||
name: `Unit`,
|
||||
isWhole: true,
|
||||
},
|
||||
{
|
||||
name: `Kg`,
|
||||
isWhole: false,
|
||||
},
|
||||
{
|
||||
name: `Gram`,
|
||||
isWhole: false,
|
||||
},
|
||||
{
|
||||
name: `Meter`,
|
||||
isWhole: false,
|
||||
},
|
||||
{
|
||||
name: `Hour`,
|
||||
isWhole: false,
|
||||
},
|
||||
{
|
||||
name: `Day`,
|
||||
isWhole: false,
|
||||
},
|
||||
];
|
||||
|
||||
async function execute(dm: DatabaseManager) {
|
||||
for (const uom of defaultUOMs) {
|
||||
const defaults = getDefaultMetaFieldValueMap();
|
||||
|
36
backend/patches/createInventoryNumberSeries.ts
Normal file
36
backend/patches/createInventoryNumberSeries.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { getDefaultMetaFieldValueMap } from '../../backend/helpers';
|
||||
import { DatabaseManager } from '../database/manager';
|
||||
|
||||
async function execute(dm: DatabaseManager) {
|
||||
const names: Record<string, string> = {
|
||||
StockMovement: 'SMOV-',
|
||||
Shipment: 'SHP-',
|
||||
};
|
||||
|
||||
for (const referenceType in names) {
|
||||
const name = names[referenceType];
|
||||
await createNumberSeries(name, referenceType, dm);
|
||||
}
|
||||
}
|
||||
|
||||
async function createNumberSeries(
|
||||
name: string,
|
||||
referenceType: string,
|
||||
dm: DatabaseManager
|
||||
) {
|
||||
const exists = await dm.db?.exists('NumberSeries', name);
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
await dm.db?.insert('NumberSeries', {
|
||||
name,
|
||||
start: 1001,
|
||||
padZeros: 4,
|
||||
current: 0,
|
||||
referenceType,
|
||||
...getDefaultMetaFieldValueMap(),
|
||||
});
|
||||
}
|
||||
|
||||
export default { execute, beforeMigrate: true };
|
@ -1,5 +1,6 @@
|
||||
import { Patch } from '../database/types';
|
||||
import addUOMs from './addUOMs';
|
||||
import createInventoryNumberSeries from './createInventoryNumberSeries';
|
||||
import fixRoundOffAccount from './fixRoundOffAccount';
|
||||
import testPatch from './testPatch';
|
||||
import updateSchemas from './updateSchemas';
|
||||
@ -20,6 +21,11 @@ export default [
|
||||
{
|
||||
name: 'fixRoundOffAccount',
|
||||
version: '0.6.3-beta.0',
|
||||
patch: fixRoundOffAccount
|
||||
patch: fixRoundOffAccount,
|
||||
},
|
||||
{
|
||||
name: 'createInventoryNumberSeries',
|
||||
version: '0.6.6-beta.0',
|
||||
patch: createInventoryNumberSeries,
|
||||
},
|
||||
] as Patch[];
|
||||
|
@ -102,7 +102,7 @@ export class Converter {
|
||||
}
|
||||
|
||||
#toDocValueMap(schemaName: string, rawValueMap: RawValueMap): DocValueMap {
|
||||
const fieldValueMap = this.db.fieldValueMap[schemaName];
|
||||
const fieldValueMap = this.db.fieldMap[schemaName];
|
||||
const docValueMap: DocValueMap = {};
|
||||
|
||||
for (const fieldname in rawValueMap) {
|
||||
@ -130,7 +130,7 @@ export class Converter {
|
||||
}
|
||||
|
||||
#toRawValueMap(schemaName: string, docValueMap: DocValueMap): RawValueMap {
|
||||
const fieldValueMap = this.db.fieldValueMap[schemaName];
|
||||
const fieldValueMap = this.db.fieldMap[schemaName];
|
||||
const rawValueMap: RawValueMap = {};
|
||||
|
||||
for (const fieldname in docValueMap) {
|
||||
|
@ -6,7 +6,12 @@ import Observable from 'fyo/utils/observable';
|
||||
import { translateSchema } from 'fyo/utils/translation';
|
||||
import { Field, RawValue, SchemaMap } from 'schemas/types';
|
||||
import { getMapFromList } from 'utils';
|
||||
import { DatabaseBase, DatabaseDemuxBase, GetAllOptions } from 'utils/db/types';
|
||||
import {
|
||||
DatabaseBase,
|
||||
DatabaseDemuxBase,
|
||||
GetAllOptions,
|
||||
QueryFilter
|
||||
} from 'utils/db/types';
|
||||
import { schemaTranslateables } from 'utils/translationHelpers';
|
||||
import { LanguageMap } from 'utils/types';
|
||||
import { Converter } from './converter';
|
||||
@ -28,6 +33,7 @@ type TotalCreditAndDebit = {
|
||||
totalCredit: number;
|
||||
totalDebit: number;
|
||||
};
|
||||
type FieldMap = Record<string, Record<string, Field>>;
|
||||
|
||||
export class DatabaseHandler extends DatabaseBase {
|
||||
#fyo: Fyo;
|
||||
@ -35,8 +41,8 @@ export class DatabaseHandler extends DatabaseBase {
|
||||
#demux: DatabaseDemuxBase;
|
||||
dbPath?: string;
|
||||
#schemaMap: SchemaMap = {};
|
||||
#fieldMap: FieldMap = {};
|
||||
observer: Observable<never> = new Observable();
|
||||
fieldValueMap: Record<string, Record<string, Field>> = {};
|
||||
|
||||
constructor(fyo: Fyo, Demux?: DatabaseDemuxConstructor) {
|
||||
super();
|
||||
@ -54,6 +60,10 @@ export class DatabaseHandler extends DatabaseBase {
|
||||
return this.#schemaMap;
|
||||
}
|
||||
|
||||
get fieldMap(): Readonly<FieldMap> {
|
||||
return this.#fieldMap;
|
||||
}
|
||||
|
||||
get isConnected() {
|
||||
return !!this.dbPath;
|
||||
}
|
||||
@ -74,11 +84,7 @@ export class DatabaseHandler extends DatabaseBase {
|
||||
|
||||
async init() {
|
||||
this.#schemaMap = (await this.#demux.getSchemaMap()) as SchemaMap;
|
||||
|
||||
for (const schemaName in this.schemaMap) {
|
||||
const fields = this.schemaMap[schemaName]!.fields!;
|
||||
this.fieldValueMap[schemaName] = getMapFromList(fields, 'fieldname');
|
||||
}
|
||||
this.#setFieldMap();
|
||||
this.observer = new Observable();
|
||||
}
|
||||
|
||||
@ -87,6 +93,7 @@ export class DatabaseHandler extends DatabaseBase {
|
||||
translateSchema(this.#schemaMap, languageMap, schemaTranslateables);
|
||||
} else {
|
||||
this.#schemaMap = (await this.#demux.getSchemaMap()) as SchemaMap;
|
||||
this.#setFieldMap();
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,7 +101,7 @@ export class DatabaseHandler extends DatabaseBase {
|
||||
await this.close();
|
||||
this.dbPath = undefined;
|
||||
this.#schemaMap = {};
|
||||
this.fieldValueMap = {};
|
||||
this.#fieldMap = {};
|
||||
}
|
||||
|
||||
async insert(
|
||||
@ -161,7 +168,7 @@ export class DatabaseHandler extends DatabaseBase {
|
||||
|
||||
const docSingleValue: SingleValue<DocValue> = [];
|
||||
for (const sv of rawSingleValue) {
|
||||
const field = this.fieldValueMap[sv.parent][sv.fieldname];
|
||||
const field = this.fieldMap[sv.parent][sv.fieldname];
|
||||
const value = Converter.toDocValue(sv.value, field, this.#fyo);
|
||||
|
||||
docSingleValue.push({
|
||||
@ -207,6 +214,16 @@ export class DatabaseHandler extends DatabaseBase {
|
||||
this.observer.trigger(`delete:${schemaName}`, name);
|
||||
}
|
||||
|
||||
async deleteAll(schemaName: string, filters: QueryFilter): Promise<number> {
|
||||
const count = (await this.#demux.call(
|
||||
'deleteAll',
|
||||
schemaName,
|
||||
filters
|
||||
)) as number;
|
||||
this.observer.trigger(`deleteAll:${schemaName}`, filters);
|
||||
return count;
|
||||
}
|
||||
|
||||
// Other
|
||||
async exists(schemaName: string, name?: string): Promise<boolean> {
|
||||
const doesExist = (await this.#demux.call(
|
||||
@ -291,6 +308,21 @@ export class DatabaseHandler extends DatabaseBase {
|
||||
)) as TotalCreditAndDebit[];
|
||||
}
|
||||
|
||||
async getStockQuantity(
|
||||
item: string,
|
||||
location?: string,
|
||||
fromDate?: string,
|
||||
toDate?: string
|
||||
): Promise<number | null> {
|
||||
return (await this.#demux.callBespoke(
|
||||
'getStockQuantity',
|
||||
item,
|
||||
location,
|
||||
fromDate,
|
||||
toDate
|
||||
)) as number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal methods
|
||||
*/
|
||||
@ -304,4 +336,15 @@ export class DatabaseHandler extends DatabaseBase {
|
||||
options
|
||||
)) as RawValueMap[];
|
||||
}
|
||||
|
||||
#setFieldMap() {
|
||||
this.#fieldMap = Object.values(this.schemaMap).reduce((acc, sch) => {
|
||||
if (!sch?.name) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc[sch?.name] = getMapFromList(sch?.fields, 'fieldname');
|
||||
return acc;
|
||||
}, {} as FieldMap);
|
||||
}
|
||||
}
|
||||
|
@ -84,6 +84,10 @@ export class Fyo {
|
||||
return this.db.schemaMap;
|
||||
}
|
||||
|
||||
get fieldMap() {
|
||||
return this.db.fieldMap;
|
||||
}
|
||||
|
||||
format(value: DocValue, field: string | Field, doc?: Doc) {
|
||||
return format(value, field, doc ?? null, this);
|
||||
}
|
||||
@ -163,8 +167,7 @@ export class Fyo {
|
||||
}
|
||||
|
||||
getField(schemaName: string, fieldname: string) {
|
||||
const schema = this.schemaMap[schemaName];
|
||||
return schema?.fields.find((f) => f.fieldname === fieldname);
|
||||
return this.fieldMap[schemaName][fieldname];
|
||||
}
|
||||
|
||||
async getValue(
|
||||
@ -230,6 +233,7 @@ export class Fyo {
|
||||
instanceId: '',
|
||||
deviceId: '',
|
||||
openCount: -1,
|
||||
appFlags: {} as Record<string, boolean>,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -129,7 +129,7 @@ export class Doc extends Observable<DocValue | Doc[]> {
|
||||
return !!this.submitted && !!this.cancelled;
|
||||
}
|
||||
|
||||
get syncing() {
|
||||
get isSyncing() {
|
||||
return this._syncing;
|
||||
}
|
||||
|
||||
@ -159,13 +159,18 @@ export class Doc extends Observable<DocValue | Doc[]> {
|
||||
|
||||
_setValuesWithoutChecks(data: DocValueMap, convertToDocValue: boolean) {
|
||||
for (const field of this.schema.fields) {
|
||||
const fieldname = field.fieldname;
|
||||
const { fieldname, fieldtype } = field;
|
||||
const value = data[field.fieldname];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const row of value) {
|
||||
this.push(fieldname, row, convertToDocValue);
|
||||
}
|
||||
} else if (
|
||||
fieldtype === FieldTypeEnum.Currency &&
|
||||
typeof value === 'number'
|
||||
) {
|
||||
this[fieldname] = this.fyo.pesa(value);
|
||||
} else if (value !== undefined && !convertToDocValue) {
|
||||
this[fieldname] = value;
|
||||
} else if (value !== undefined) {
|
||||
@ -269,13 +274,13 @@ export class Doc extends Observable<DocValue | Doc[]> {
|
||||
}
|
||||
|
||||
async _applyChange(
|
||||
fieldname: string,
|
||||
changedFieldname: string,
|
||||
retriggerChildDocApplyChange?: boolean
|
||||
): Promise<boolean> {
|
||||
await this._applyFormula(fieldname, retriggerChildDocApplyChange);
|
||||
await this._applyFormula(changedFieldname, retriggerChildDocApplyChange);
|
||||
await this.trigger('change', {
|
||||
doc: this,
|
||||
changed: fieldname,
|
||||
changed: changedFieldname,
|
||||
});
|
||||
|
||||
return true;
|
||||
@ -666,16 +671,17 @@ export class Doc extends Observable<DocValue | Doc[]> {
|
||||
}
|
||||
|
||||
async _applyFormula(
|
||||
fieldname?: string,
|
||||
changedFieldname?: string,
|
||||
retriggerChildDocApplyChange?: boolean
|
||||
): Promise<boolean> {
|
||||
const doc = this;
|
||||
let changed = await this._callAllTableFieldsApplyFormula(fieldname);
|
||||
changed = (await this._applyFormulaForFields(doc, fieldname)) || changed;
|
||||
let changed = await this._callAllTableFieldsApplyFormula(changedFieldname);
|
||||
changed =
|
||||
(await this._applyFormulaForFields(doc, changedFieldname)) || changed;
|
||||
|
||||
if (changed && retriggerChildDocApplyChange) {
|
||||
await this._callAllTableFieldsApplyFormula(fieldname);
|
||||
await this._applyFormulaForFields(doc, fieldname);
|
||||
await this._callAllTableFieldsApplyFormula(changedFieldname);
|
||||
await this._applyFormulaForFields(doc, changedFieldname);
|
||||
}
|
||||
|
||||
return changed;
|
||||
@ -851,6 +857,7 @@ export class Doc extends Observable<DocValue | Doc[]> {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.trigger('beforeCancel');
|
||||
await this.trigger('beforeCancel');
|
||||
await this.setAndSync('cancelled', true);
|
||||
await this.trigger('afterCancel');
|
||||
@ -902,6 +909,7 @@ export class Doc extends Observable<DocValue | Doc[]> {
|
||||
if (convertToFloat) {
|
||||
return sum.float;
|
||||
}
|
||||
|
||||
return sum;
|
||||
}
|
||||
|
||||
|
@ -105,10 +105,45 @@ export function shouldApplyFormula(field: Field, doc: Doc, fieldname?: string) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (doc.isSyncing && dependsOn.length > 0) {
|
||||
return shouldApplyFormulaPreSync(field.fieldname, dependsOn, doc);
|
||||
}
|
||||
|
||||
const value = doc.get(field.fieldname);
|
||||
return getIsNullOrUndef(value);
|
||||
}
|
||||
|
||||
function shouldApplyFormulaPreSync(
|
||||
fieldname: string,
|
||||
dependsOn: string[],
|
||||
doc: Doc
|
||||
): boolean {
|
||||
if (isDocValueTruthy(doc.get(fieldname))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const d of dependsOn) {
|
||||
const isSet = isDocValueTruthy(doc.get(d));
|
||||
if (isSet) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isDocValueTruthy(docValue: DocValue | Doc[]) {
|
||||
if (isPesa(docValue)) {
|
||||
return !(docValue as Money).isZero();
|
||||
}
|
||||
|
||||
if (Array.isArray(docValue)) {
|
||||
return docValue.length > 0;
|
||||
}
|
||||
|
||||
return !!docValue;
|
||||
}
|
||||
|
||||
export function setChildDocIdx(childDocs: Doc[]) {
|
||||
for (const idx in childDocs) {
|
||||
childDocs[idx].idx = +idx;
|
||||
|
@ -64,6 +64,8 @@ export interface Action {
|
||||
label: string;
|
||||
action: (doc: Doc, router: Router) => Promise<void> | void;
|
||||
condition?: (doc: Doc) => boolean;
|
||||
group?: string;
|
||||
type?: 'primary' | 'secondary';
|
||||
component?: {
|
||||
template?: string;
|
||||
};
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { strictEqual } from 'assert';
|
||||
import { assertThrows } from 'backend/database/tests/helpers';
|
||||
import Observable from 'fyo/utils/observable';
|
||||
import test from 'tape';
|
||||
|
||||
@ -30,55 +31,69 @@ const listenerBOnce = (value: number) => {
|
||||
strictEqual(params.b, value, 'listenerBOnce');
|
||||
};
|
||||
|
||||
test('set A One', function (t) {
|
||||
test('set A One', (t) => {
|
||||
t.equal(obs.hasListener(ObsEvent.A), false, 'pre');
|
||||
|
||||
obs.once(ObsEvent.A, listenerAOnce);
|
||||
t.equal(obs.hasListener(ObsEvent.A), true, 'non specific');
|
||||
t.equal(obs.hasListener(ObsEvent.A, listenerAOnce), true, 'specific once');
|
||||
t.equal(obs.hasListener(ObsEvent.A, listenerAEvery), false, 'specific every');
|
||||
t.end()
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('set A Two', function (t) {
|
||||
test('set A Two', (t) => {
|
||||
obs.on(ObsEvent.A, listenerAEvery);
|
||||
t.equal(obs.hasListener(ObsEvent.A), true, 'non specific');
|
||||
t.equal(obs.hasListener(ObsEvent.A, listenerAOnce), true, 'specific once');
|
||||
t.equal(obs.hasListener(ObsEvent.A, listenerAEvery), true, 'specific every');
|
||||
t.end()
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('set B', function (t) {
|
||||
test('set B', (t) => {
|
||||
t.equal(obs.hasListener(ObsEvent.B), false, 'pre');
|
||||
|
||||
obs.once(ObsEvent.B, listenerBOnce);
|
||||
t.equal(obs.hasListener(ObsEvent.A, listenerBOnce), false, 'specific false');
|
||||
t.equal(obs.hasListener(ObsEvent.B, listenerBOnce), true, 'specific true');
|
||||
t.end()
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('trigger A 0', async function (t) {
|
||||
test('trigger A 0', async (t) => {
|
||||
await obs.trigger(ObsEvent.A, params.aOne);
|
||||
t.equal(obs.hasListener(ObsEvent.A), true, 'non specific');
|
||||
t.equal(obs.hasListener(ObsEvent.A, listenerAOnce), false, 'specific');
|
||||
});
|
||||
|
||||
test('trigger A 1', async function (t) {
|
||||
test('trigger A 1', async (t) => {
|
||||
t.equal(obs.hasListener(ObsEvent.A, listenerAEvery), true, 'specific pre');
|
||||
await obs.trigger(ObsEvent.A, params.aTwo);
|
||||
t.equal(obs.hasListener(ObsEvent.A, listenerAEvery), true, 'specific post');
|
||||
});
|
||||
|
||||
test('trigger B', async function (t) {
|
||||
test('trigger B', async (t) => {
|
||||
t.equal(obs.hasListener(ObsEvent.B, listenerBOnce), true, 'specific pre');
|
||||
await obs.trigger(ObsEvent.B, params.b);
|
||||
t.equal(obs.hasListener(ObsEvent.B, listenerBOnce), false, 'specific post');
|
||||
});
|
||||
|
||||
test('remove A', async function (t) {
|
||||
test('remove A', async (t) => {
|
||||
obs.off(ObsEvent.A, listenerAEvery);
|
||||
t.equal(obs.hasListener(ObsEvent.A, listenerAEvery), false, 'specific pre');
|
||||
|
||||
t.equal(counter, 2, 'incorrect counter');
|
||||
await obs.trigger(ObsEvent.A, 777);
|
||||
});
|
||||
|
||||
test('observable trigger error propagation', async (t) => {
|
||||
const obs = new Observable();
|
||||
obs.on('testOne', () => {
|
||||
throw new Error('stuff');
|
||||
});
|
||||
|
||||
await assertThrows(async () => {
|
||||
await obs.trigger('testOne');
|
||||
t.ok(false, 'trigger should throw error');
|
||||
});
|
||||
|
||||
t.ok(true, 'assert throws success');
|
||||
});
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
DEFAULT_CURRENCY,
|
||||
DEFAULT_DATE_FORMAT,
|
||||
DEFAULT_DISPLAY_PRECISION,
|
||||
DEFAULT_LOCALE,
|
||||
DEFAULT_LOCALE
|
||||
} from './consts';
|
||||
|
||||
export function format(
|
||||
@ -24,6 +24,14 @@ export function format(
|
||||
|
||||
const field: Field = getField(df);
|
||||
|
||||
if (field.fieldtype === FieldTypeEnum.Float) {
|
||||
return Number(value).toFixed(fyo.singles.SystemSettings?.displayPrecision);
|
||||
}
|
||||
|
||||
if (field.fieldtype === FieldTypeEnum.Int) {
|
||||
return Math.trunc(Number(value)).toString();
|
||||
}
|
||||
|
||||
if (field.fieldtype === FieldTypeEnum.Currency) {
|
||||
return formatCurrency(value, field, doc, fyo);
|
||||
}
|
||||
@ -32,6 +40,10 @@ export function format(
|
||||
return formatDate(value, fyo);
|
||||
}
|
||||
|
||||
if (field.fieldtype === FieldTypeEnum.Datetime) {
|
||||
return formatDatetime(value, fyo);
|
||||
}
|
||||
|
||||
if (field.fieldtype === FieldTypeEnum.Check) {
|
||||
return Boolean(value).toString();
|
||||
}
|
||||
@ -43,18 +55,35 @@ export function format(
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function toDatetime(value: DocValue) {
|
||||
if (typeof value === 'string') {
|
||||
return DateTime.fromISO(value);
|
||||
} else if (value instanceof Date) {
|
||||
return DateTime.fromJSDate(value);
|
||||
} else {
|
||||
return DateTime.fromSeconds(value as number);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDatetime(value: DocValue, fyo: Fyo): string {
|
||||
const dateFormat =
|
||||
(fyo.singles.SystemSettings?.dateFormat as string) ?? DEFAULT_DATE_FORMAT;
|
||||
const formattedDatetime = toDatetime(value).toFormat(
|
||||
`${dateFormat} HH:mm:ss`
|
||||
);
|
||||
|
||||
if (value === 'Invalid DateTime') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return formattedDatetime;
|
||||
}
|
||||
|
||||
function formatDate(value: DocValue, fyo: Fyo): string {
|
||||
const dateFormat =
|
||||
(fyo.singles.SystemSettings?.dateFormat as string) ?? DEFAULT_DATE_FORMAT;
|
||||
|
||||
let dateValue: DateTime;
|
||||
if (typeof value === 'string') {
|
||||
dateValue = DateTime.fromISO(value);
|
||||
} else if (value instanceof Date) {
|
||||
dateValue = DateTime.fromJSDate(value);
|
||||
} else {
|
||||
dateValue = DateTime.fromSeconds(value as number);
|
||||
}
|
||||
const dateValue: DateTime = toDatetime(value);
|
||||
|
||||
const formattedDate = dateValue.toFormat(dateFormat);
|
||||
if (value === 'Invalid DateTime') {
|
||||
|
@ -26,28 +26,56 @@ export abstract class Transactional extends Doc {
|
||||
return true;
|
||||
}
|
||||
|
||||
abstract getPosting(): Promise<LedgerPosting>;
|
||||
abstract getPosting(): Promise<LedgerPosting | null>;
|
||||
|
||||
async validate() {
|
||||
await super.validate();
|
||||
if (!this.isTransactional) {
|
||||
return;
|
||||
}
|
||||
|
||||
const posting = await this.getPosting();
|
||||
if (posting === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
posting.validate();
|
||||
}
|
||||
|
||||
async afterSubmit(): Promise<void> {
|
||||
await super.afterSubmit();
|
||||
if (!this.isTransactional) {
|
||||
return;
|
||||
}
|
||||
|
||||
const posting = await this.getPosting();
|
||||
if (posting === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await posting.post();
|
||||
}
|
||||
|
||||
async afterCancel(): Promise<void> {
|
||||
await super.afterCancel();
|
||||
if (!this.isTransactional) {
|
||||
return;
|
||||
}
|
||||
|
||||
const posting = await this.getPosting();
|
||||
if (posting === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await posting.postReverse();
|
||||
}
|
||||
|
||||
async afterDelete(): Promise<void> {
|
||||
await super.afterDelete();
|
||||
if (!this.isTransactional) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ledgerEntryIds = (await this.fyo.db.getAll(
|
||||
ModelNameEnum.AccountingLedgerEntry,
|
||||
{
|
||||
|
@ -4,7 +4,7 @@ import {
|
||||
FiltersMap,
|
||||
ListsMap,
|
||||
ReadOnlyMap,
|
||||
ValidationMap
|
||||
ValidationMap,
|
||||
} from 'fyo/model/types';
|
||||
import { validateEmail } from 'fyo/model/validationFunction';
|
||||
import { createDiscountAccount } from 'src/setup/setupInstance';
|
||||
@ -12,6 +12,8 @@ import { getCountryInfo } from 'utils/misc';
|
||||
|
||||
export class AccountingSettings extends Doc {
|
||||
enableDiscounting?: boolean;
|
||||
enableInventory?: boolean;
|
||||
|
||||
static filters: FiltersMap = {
|
||||
writeOffAccount: () => ({
|
||||
isGroup: false,
|
||||
@ -39,6 +41,9 @@ export class AccountingSettings extends Doc {
|
||||
enableDiscounting: () => {
|
||||
return !!this.enableDiscounting;
|
||||
},
|
||||
enableInventory: () => {
|
||||
return !!this.enableInventory;
|
||||
},
|
||||
};
|
||||
|
||||
async change(ch: ChangeArg) {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Doc } from 'fyo/model/doc';
|
||||
import { FiltersMap } from 'fyo/model/types';
|
||||
import { FiltersMap, HiddenMap } from 'fyo/model/types';
|
||||
import { ModelNameEnum } from 'models/types';
|
||||
|
||||
export class Defaults extends Doc {
|
||||
@ -7,24 +7,16 @@ export class Defaults extends Doc {
|
||||
purchaseInvoiceNumberSeries?: string;
|
||||
journalEntryNumberSeries?: string;
|
||||
paymentNumberSeries?: string;
|
||||
stockMovementNumberSeries?: string;
|
||||
shipmentNumberSeries?: string;
|
||||
purchaseReceiptNumberSeries?: string;
|
||||
|
||||
salesInvoiceTerms?: string;
|
||||
purchaseInvoiceTerms?: string;
|
||||
shipmentTerms?: string;
|
||||
purchaseReceiptTerms?: string;
|
||||
|
||||
static filters: FiltersMap = {
|
||||
salesInvoiceNumberSeries: () => ({
|
||||
referenceType: ModelNameEnum.SalesInvoice,
|
||||
}),
|
||||
purchaseInvoiceNumberSeries: () => ({
|
||||
referenceType: ModelNameEnum.PurchaseInvoice,
|
||||
}),
|
||||
journalEntryNumberSeries: () => ({
|
||||
referenceType: ModelNameEnum.JournalEntry,
|
||||
}),
|
||||
paymentNumberSeries: () => ({ referenceType: ModelNameEnum.Payment }),
|
||||
};
|
||||
|
||||
static createFilters: FiltersMap = {
|
||||
static commonFilters = {
|
||||
salesInvoiceNumberSeries: () => ({
|
||||
referenceType: ModelNameEnum.SalesInvoice,
|
||||
}),
|
||||
@ -37,6 +29,30 @@ export class Defaults extends Doc {
|
||||
paymentNumberSeries: () => ({
|
||||
referenceType: ModelNameEnum.Payment,
|
||||
}),
|
||||
stockMovementNumberSeries: () => ({
|
||||
referenceType: ModelNameEnum.StockMovement,
|
||||
}),
|
||||
shipmentNumberSeries: () => ({
|
||||
referenceType: ModelNameEnum.Shipment,
|
||||
}),
|
||||
purchaseReceiptNumberSeries: () => ({
|
||||
referenceType: ModelNameEnum.PurchaseReceipt,
|
||||
}),
|
||||
};
|
||||
|
||||
static filters: FiltersMap = this.commonFilters;
|
||||
static createFilters: FiltersMap = this.commonFilters;
|
||||
|
||||
getInventoryHidden() {
|
||||
return () => !this.fyo.singles.AccountingSettings?.enableInventory;
|
||||
}
|
||||
|
||||
hidden: HiddenMap = {
|
||||
stockMovementNumberSeries: this.getInventoryHidden(),
|
||||
shipmentNumberSeries: this.getInventoryHidden(),
|
||||
purchaseReceiptNumberSeries: this.getInventoryHidden(),
|
||||
shipmentTerms: this.getInventoryHidden(),
|
||||
purchaseReceiptTerms: this.getInventoryHidden(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -48,4 +64,7 @@ export const numberSeriesDefaultsMap: Record<
|
||||
[ModelNameEnum.PurchaseInvoice]: 'purchaseInvoiceNumberSeries',
|
||||
[ModelNameEnum.JournalEntry]: 'journalEntryNumberSeries',
|
||||
[ModelNameEnum.Payment]: 'paymentNumberSeries',
|
||||
[ModelNameEnum.StockMovement]: 'stockMovementNumberSeries',
|
||||
[ModelNameEnum.Shipment]: 'shipmentNumberSeries',
|
||||
[ModelNameEnum.PurchaseReceipt]: 'purchaseReceiptNumberSeries',
|
||||
};
|
||||
|
@ -2,21 +2,30 @@ import { Fyo } from 'fyo';
|
||||
import { DocValue, DocValueMap } from 'fyo/core/types';
|
||||
import { Doc } from 'fyo/model/doc';
|
||||
import {
|
||||
CurrenciesMap, DefaultMap,
|
||||
Action,
|
||||
CurrenciesMap,
|
||||
DefaultMap,
|
||||
FiltersMap,
|
||||
FormulaMap,
|
||||
HiddenMap
|
||||
HiddenMap,
|
||||
} from 'fyo/model/types';
|
||||
import { DEFAULT_CURRENCY } from 'fyo/utils/consts';
|
||||
import { ValidationError } from 'fyo/utils/errors';
|
||||
import { getExchangeRate, getNumberSeries } from 'models/helpers';
|
||||
import {
|
||||
getExchangeRate,
|
||||
getInvoiceActions,
|
||||
getNumberSeries,
|
||||
} from 'models/helpers';
|
||||
import { InventorySettings } from 'models/inventory/InventorySettings';
|
||||
import { StockTransfer } from 'models/inventory/StockTransfer';
|
||||
import { Transactional } from 'models/Transactional/Transactional';
|
||||
import { ModelNameEnum } from 'models/types';
|
||||
import { Money } from 'pesa';
|
||||
import { FieldTypeEnum, Schema } from 'schemas/types';
|
||||
import { getIsNullOrUndef, safeParseFloat } from 'utils';
|
||||
import { getIsNullOrUndef, joinMapLists, safeParseFloat } from 'utils';
|
||||
import { Defaults } from '../Defaults/Defaults';
|
||||
import { InvoiceItem } from '../InvoiceItem/InvoiceItem';
|
||||
import { Item } from '../Item/Item';
|
||||
import { Party } from '../Party/Party';
|
||||
import { Payment } from '../Payment/Payment';
|
||||
import { Tax } from '../Tax/Tax';
|
||||
@ -39,6 +48,7 @@ export abstract class Invoice extends Transactional {
|
||||
discountAmount?: Money;
|
||||
discountPercent?: number;
|
||||
discountAfterTax?: boolean;
|
||||
stockNotTransferred?: number;
|
||||
|
||||
submitted?: boolean;
|
||||
cancelled?: boolean;
|
||||
@ -63,6 +73,28 @@ export abstract class Invoice extends Transactional {
|
||||
return this.fyo.singles.SystemSettings?.currency ?? DEFAULT_CURRENCY;
|
||||
}
|
||||
|
||||
get stockTransferSchemaName() {
|
||||
return this.isSales
|
||||
? ModelNameEnum.Shipment
|
||||
: ModelNameEnum.PurchaseReceipt;
|
||||
}
|
||||
|
||||
get hasLinkedTransfers() {
|
||||
if (!this.submitted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.getStockTransferred() > 0;
|
||||
}
|
||||
|
||||
get hasLinkedPayments() {
|
||||
if (!this.submitted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !this.baseGrandTotal?.eq(this.outstandingAmount!);
|
||||
}
|
||||
|
||||
constructor(schema: Schema, data: DocValueMap, fyo: Fyo) {
|
||||
super(schema, data, fyo);
|
||||
this._setGetCurrencies();
|
||||
@ -341,8 +373,40 @@ export abstract class Invoice extends Transactional {
|
||||
return this.baseGrandTotal!;
|
||||
},
|
||||
},
|
||||
stockNotTransferred: {
|
||||
formula: async () => {
|
||||
if (this.submitted) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.getStockNotTransferred();
|
||||
},
|
||||
dependsOn: ['items'],
|
||||
},
|
||||
};
|
||||
|
||||
getStockTransferred() {
|
||||
return (this.items ?? []).reduce(
|
||||
(acc, item) =>
|
||||
(item.quantity ?? 0) - (item.stockNotTransferred ?? 0) + acc,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
getTotalQuantity() {
|
||||
return (this.items ?? []).reduce(
|
||||
(acc, item) => acc + (item.quantity ?? 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
getStockNotTransferred() {
|
||||
return (this.items ?? []).reduce(
|
||||
(acc, item) => (item.stockNotTransferred ?? 0) + acc,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
getItemDiscountedAmounts() {
|
||||
let itemDiscountedAmounts = this.fyo.pesa(0);
|
||||
for (const item of this.items ?? []) {
|
||||
@ -412,4 +476,226 @@ export abstract class Invoice extends Transactional {
|
||||
this.getCurrencies[fieldname] ??= this._getCurrency.bind(this);
|
||||
}
|
||||
}
|
||||
|
||||
getPayment(): Payment | null {
|
||||
if (!this.isSubmitted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const outstandingAmount = this.outstandingAmount;
|
||||
if (!outstandingAmount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.outstandingAmount?.isZero()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const accountField = this.isSales ? 'account' : 'paymentAccount';
|
||||
const data = {
|
||||
party: this.party,
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
paymentType: this.isSales ? 'Receive' : 'Pay',
|
||||
amount: this.outstandingAmount,
|
||||
[accountField]: this.account,
|
||||
for: [
|
||||
{
|
||||
referenceType: this.schemaName,
|
||||
referenceName: this.name,
|
||||
amount: this.outstandingAmount,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return this.fyo.doc.getNewDoc(ModelNameEnum.Payment, data) as Payment;
|
||||
}
|
||||
|
||||
async getStockTransfer(): Promise<StockTransfer | null> {
|
||||
if (!this.isSubmitted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.stockNotTransferred) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const schemaName = this.stockTransferSchemaName;
|
||||
|
||||
const defaults = (this.fyo.singles.Defaults as Defaults) ?? {};
|
||||
let terms;
|
||||
if (this.isSales) {
|
||||
terms = defaults.shipmentTerms ?? '';
|
||||
} else {
|
||||
terms = defaults.purchaseReceiptTerms ?? '';
|
||||
}
|
||||
|
||||
const data = {
|
||||
party: this.party,
|
||||
date: new Date().toISOString(),
|
||||
terms,
|
||||
backReference: this.name,
|
||||
};
|
||||
|
||||
const location =
|
||||
(this.fyo.singles.InventorySettings as InventorySettings)
|
||||
.defaultLocation ?? null;
|
||||
|
||||
const transfer = this.fyo.doc.getNewDoc(schemaName, data) as StockTransfer;
|
||||
for (const row of this.items ?? []) {
|
||||
if (!row.item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const itemDoc = (await row.loadAndGetLink('item')) as Item;
|
||||
const item = row.item;
|
||||
const quantity = row.stockNotTransferred;
|
||||
const trackItem = itemDoc.trackItem;
|
||||
let rate = row.rate as Money;
|
||||
|
||||
if (this.exchangeRate && this.exchangeRate > 1) {
|
||||
rate = rate.mul(this.exchangeRate);
|
||||
}
|
||||
|
||||
if (!quantity || !trackItem) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await transfer.append('items', {
|
||||
item,
|
||||
quantity,
|
||||
location,
|
||||
rate,
|
||||
});
|
||||
}
|
||||
|
||||
if (!transfer.items?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return transfer;
|
||||
}
|
||||
|
||||
async beforeCancel(): Promise<void> {
|
||||
await super.beforeCancel();
|
||||
await this._validateStockTransferCancelled();
|
||||
}
|
||||
|
||||
async beforeDelete(): Promise<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
async getLinkedPayments() {
|
||||
if (!this.hasLinkedPayments) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const paymentFors = (await this.fyo.db.getAllRaw('PaymentFor', {
|
||||
fields: ['parent', 'amount'],
|
||||
filters: { referenceName: this.name!, referenceType: this.schemaName },
|
||||
})) as { parent: string; amount: string }[];
|
||||
|
||||
const payments = (await this.fyo.db.getAllRaw('Payment', {
|
||||
fields: ['name', 'date', 'submitted', 'cancelled'],
|
||||
filters: { name: ['in', paymentFors.map((p) => p.parent)] },
|
||||
})) as {
|
||||
name: string;
|
||||
date: string;
|
||||
submitted: number;
|
||||
cancelled: number;
|
||||
}[];
|
||||
|
||||
return joinMapLists(payments, paymentFors, 'name', 'parent')
|
||||
.map((j) => ({
|
||||
name: j.name,
|
||||
date: new Date(j.date),
|
||||
submitted: !!j.submitted,
|
||||
cancelled: !!j.cancelled,
|
||||
amount: this.fyo.pesa(j.amount),
|
||||
}))
|
||||
.sort((a, b) => a.date.valueOf() - b.date.valueOf());
|
||||
}
|
||||
|
||||
async getLinkedStockTransfers() {
|
||||
if (!this.hasLinkedTransfers) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const schemaName = this.stockTransferSchemaName;
|
||||
const transfers = (await this.fyo.db.getAllRaw(schemaName, {
|
||||
fields: ['name', 'date', 'submitted', 'cancelled'],
|
||||
filters: { backReference: this.name! },
|
||||
})) as {
|
||||
name: string;
|
||||
date: string;
|
||||
submitted: number;
|
||||
cancelled: number;
|
||||
}[];
|
||||
|
||||
const itemSchemaName = schemaName + 'Item';
|
||||
const transferItems = (await this.fyo.db.getAllRaw(itemSchemaName, {
|
||||
fields: ['parent', 'quantity', 'location', 'amount'],
|
||||
filters: {
|
||||
parent: ['in', transfers.map((t) => t.name)],
|
||||
item: ['in', this.items!.map((i) => i.item!)],
|
||||
},
|
||||
})) as {
|
||||
parent: string;
|
||||
quantity: number;
|
||||
location: string;
|
||||
amount: string;
|
||||
}[];
|
||||
|
||||
return joinMapLists(transfers, transferItems, 'name', 'parent')
|
||||
.map((j) => ({
|
||||
name: j.name,
|
||||
date: new Date(j.date),
|
||||
submitted: !!j.submitted,
|
||||
cancelled: !!j.cancelled,
|
||||
amount: this.fyo.pesa(j.amount),
|
||||
location: j.location,
|
||||
quantity: j.quantity,
|
||||
}))
|
||||
.sort((a, b) => a.date.valueOf() - b.date.valueOf());
|
||||
}
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ import {
|
||||
FiltersMap,
|
||||
FormulaMap,
|
||||
HiddenMap,
|
||||
ValidationMap
|
||||
ValidationMap,
|
||||
} from 'fyo/model/types';
|
||||
import { DEFAULT_CURRENCY } from 'fyo/utils/consts';
|
||||
import { ValidationError } from 'fyo/utils/errors';
|
||||
@ -14,14 +14,17 @@ import { ModelNameEnum } from 'models/types';
|
||||
import { Money } from 'pesa';
|
||||
import { FieldTypeEnum, Schema } from 'schemas/types';
|
||||
import { Invoice } from '../Invoice/Invoice';
|
||||
import { Item } from '../Item/Item';
|
||||
|
||||
export abstract class InvoiceItem extends Doc {
|
||||
item?: string;
|
||||
account?: string;
|
||||
amount?: Money;
|
||||
parentdoc?: Invoice;
|
||||
rate?: Money;
|
||||
quantity?: number;
|
||||
tax?: string;
|
||||
stockNotTransferred?: number;
|
||||
|
||||
setItemDiscountAmount?: boolean;
|
||||
itemDiscountAmount?: Money;
|
||||
@ -260,6 +263,21 @@ export abstract class InvoiceItem extends Doc {
|
||||
'item',
|
||||
],
|
||||
},
|
||||
stockNotTransferred: {
|
||||
formula: async () => {
|
||||
if (this.parentdoc?.isSubmitted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = (await this.loadAndGetLink('item')) as Item;
|
||||
if (!item.trackItem) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this.quantity;
|
||||
},
|
||||
dependsOn: ['item', 'quantity'],
|
||||
},
|
||||
};
|
||||
|
||||
validations: ValidationMap = {
|
||||
|
@ -5,7 +5,9 @@ import {
|
||||
Action,
|
||||
FiltersMap,
|
||||
FormulaMap,
|
||||
HiddenMap,
|
||||
ListViewSettings,
|
||||
ReadOnlyMap,
|
||||
ValidationMap,
|
||||
} from 'fyo/model/types';
|
||||
import { ValidationError } from 'fyo/utils/errors';
|
||||
@ -13,6 +15,9 @@ import { Money } from 'pesa';
|
||||
import { AccountRootTypeEnum, AccountTypeEnum } from '../Account/types';
|
||||
|
||||
export class Item extends Doc {
|
||||
trackItem?: boolean;
|
||||
itemType?: 'Product' | 'Service';
|
||||
|
||||
formulas: FormulaMap = {
|
||||
incomeAccount: {
|
||||
formula: async () => {
|
||||
@ -28,6 +33,11 @@ export class Item extends Doc {
|
||||
},
|
||||
expenseAccount: {
|
||||
formula: async () => {
|
||||
if (this.trackItem) {
|
||||
return this.fyo.singles.InventorySettings
|
||||
?.stockReceivedButNotBilled as string;
|
||||
}
|
||||
|
||||
const cogs = await this.fyo.db.getAllRaw('Account', {
|
||||
filters: {
|
||||
accountType: AccountTypeEnum['Cost of Goods Sold'],
|
||||
@ -40,7 +50,7 @@ export class Item extends Doc {
|
||||
return cogs[0].name as string;
|
||||
}
|
||||
},
|
||||
dependsOn: ['itemType'],
|
||||
dependsOn: ['itemType', 'trackItem'],
|
||||
},
|
||||
};
|
||||
|
||||
@ -49,9 +59,11 @@ export class Item extends Doc {
|
||||
isGroup: false,
|
||||
rootType: AccountRootTypeEnum.Income,
|
||||
}),
|
||||
expenseAccount: () => ({
|
||||
expenseAccount: (doc) => ({
|
||||
isGroup: false,
|
||||
rootType: AccountRootTypeEnum.Expense,
|
||||
rootType: doc.trackItem
|
||||
? AccountRootTypeEnum.Liability
|
||||
: AccountRootTypeEnum.Expense,
|
||||
}),
|
||||
};
|
||||
|
||||
@ -99,4 +111,17 @@ export class Item extends Doc {
|
||||
columns: ['name', 'unit', 'tax', 'rate'],
|
||||
};
|
||||
}
|
||||
|
||||
hidden: HiddenMap = {
|
||||
trackItem: () =>
|
||||
!this.fyo.singles.AccountingSettings?.enableInventory ||
|
||||
this.itemType !== 'Product' ||
|
||||
(this.inserted && !this.trackItem),
|
||||
};
|
||||
|
||||
readOnly: ReadOnlyMap = {
|
||||
unit: () => this.inserted,
|
||||
itemType: () => this.inserted,
|
||||
trackItem: () => this.inserted,
|
||||
};
|
||||
}
|
||||
|
@ -10,8 +10,7 @@ import { DateTime } from 'luxon';
|
||||
import {
|
||||
getDocStatus,
|
||||
getLedgerLinkAction,
|
||||
getNumberSeries,
|
||||
getStatusMap,
|
||||
getNumberSeries, getStatusText,
|
||||
statusColor
|
||||
} from 'models/helpers';
|
||||
import { Transactional } from 'models/Transactional/Transactional';
|
||||
@ -64,7 +63,7 @@ export class JournalEntry extends Transactional {
|
||||
render(doc) {
|
||||
const status = getDocStatus(doc);
|
||||
const color = statusColor[status] ?? 'gray';
|
||||
const label = getStatusMap()[status];
|
||||
const label = getStatusText(status);
|
||||
|
||||
return {
|
||||
template: `<Badge class="text-xs" color="${color}">${label}</Badge>`,
|
||||
|
@ -10,15 +10,13 @@ import {
|
||||
HiddenMap,
|
||||
ListViewSettings,
|
||||
RequiredMap,
|
||||
ValidationMap
|
||||
ValidationMap,
|
||||
} from 'fyo/model/types';
|
||||
import { NotFoundError, ValidationError } from 'fyo/utils/errors';
|
||||
import {
|
||||
getDocStatus,
|
||||
getDocStatusListColumn,
|
||||
getLedgerLinkAction,
|
||||
getNumberSeries,
|
||||
getStatusMap,
|
||||
statusColor
|
||||
} from 'models/helpers';
|
||||
import { LedgerPosting } from 'models/Transactional/LedgerPosting';
|
||||
import { Transactional } from 'models/Transactional/Transactional';
|
||||
@ -619,27 +617,7 @@ export class Payment extends Transactional {
|
||||
|
||||
static getListViewSettings(fyo: Fyo): ListViewSettings {
|
||||
return {
|
||||
columns: [
|
||||
'name',
|
||||
{
|
||||
label: t`Status`,
|
||||
fieldname: 'status',
|
||||
fieldtype: 'Select',
|
||||
size: 'small',
|
||||
render(doc) {
|
||||
const status = getDocStatus(doc);
|
||||
const color = statusColor[status] ?? 'gray';
|
||||
const label = getStatusMap()[status];
|
||||
|
||||
return {
|
||||
template: `<Badge class="text-xs" color="${color}">${label}</Badge>`,
|
||||
};
|
||||
},
|
||||
},
|
||||
'party',
|
||||
'date',
|
||||
'amount',
|
||||
],
|
||||
columns: ['name', getDocStatusListColumn(), 'party', 'date', 'amount'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -35,10 +35,6 @@ export class PurchaseInvoice extends Invoice {
|
||||
return posting;
|
||||
}
|
||||
|
||||
static getActions(fyo: Fyo): Action[] {
|
||||
return getInvoiceActions(ModelNameEnum.PurchaseInvoice, fyo);
|
||||
}
|
||||
|
||||
static getListViewSettings(): ListViewSettings {
|
||||
return {
|
||||
formRoute: (name) => `/edit/PurchaseInvoice/${name}`,
|
||||
@ -52,4 +48,8 @@ export class PurchaseInvoice extends Invoice {
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
static getActions(fyo: Fyo): Action[] {
|
||||
return getInvoiceActions(fyo, ModelNameEnum.PurchaseInvoice);
|
||||
}
|
||||
}
|
||||
|
@ -35,10 +35,6 @@ export class SalesInvoice extends Invoice {
|
||||
return posting;
|
||||
}
|
||||
|
||||
static getActions(fyo: Fyo): Action[] {
|
||||
return getInvoiceActions(ModelNameEnum.SalesInvoice, fyo);
|
||||
}
|
||||
|
||||
static getListViewSettings(): ListViewSettings {
|
||||
return {
|
||||
formRoute: (name) => `/edit/SalesInvoice/${name}`,
|
||||
@ -52,4 +48,8 @@ export class SalesInvoice extends Invoice {
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
static getActions(fyo: Fyo): Action[] {
|
||||
return getInvoiceActions(fyo, ModelNameEnum.SalesInvoice);
|
||||
}
|
||||
}
|
||||
|
@ -7,68 +7,98 @@ import { safeParseFloat } from 'utils/index';
|
||||
import { Router } from 'vue-router';
|
||||
import {
|
||||
AccountRootType,
|
||||
AccountRootTypeEnum
|
||||
AccountRootTypeEnum,
|
||||
} from './baseModels/Account/types';
|
||||
import {
|
||||
Defaults,
|
||||
numberSeriesDefaultsMap
|
||||
numberSeriesDefaultsMap,
|
||||
} from './baseModels/Defaults/Defaults';
|
||||
import { Invoice } from './baseModels/Invoice/Invoice';
|
||||
import { InvoiceStatus, ModelNameEnum } from './types';
|
||||
|
||||
export function getInvoiceActions(
|
||||
schemaName: ModelNameEnum.PurchaseInvoice | ModelNameEnum.SalesInvoice,
|
||||
fyo: Fyo
|
||||
fyo: Fyo,
|
||||
schemaName: ModelNameEnum.SalesInvoice | ModelNameEnum.PurchaseInvoice
|
||||
): Action[] {
|
||||
return [
|
||||
{
|
||||
label: fyo.t`Make Payment`,
|
||||
condition: (doc: Doc) =>
|
||||
doc.isSubmitted && !(doc.outstandingAmount as Money).isZero(),
|
||||
action: async function makePayment(doc: Doc) {
|
||||
const payment = fyo.doc.getNewDoc('Payment');
|
||||
payment.once('afterSync', async () => {
|
||||
await payment.submit();
|
||||
});
|
||||
|
||||
const isSales = schemaName === 'SalesInvoice';
|
||||
const party = doc.party as string;
|
||||
const paymentType = isSales ? 'Receive' : 'Pay';
|
||||
const hideAccountField = isSales ? 'account' : 'paymentAccount';
|
||||
|
||||
const { openQuickEdit } = await import('src/utils/ui');
|
||||
await openQuickEdit({
|
||||
schemaName: 'Payment',
|
||||
name: payment.name as string,
|
||||
hideFields: ['party', 'paymentType', 'for'],
|
||||
defaults: {
|
||||
party,
|
||||
[hideAccountField]: doc.account,
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
paymentType,
|
||||
for: [
|
||||
{
|
||||
referenceType: doc.schemaName,
|
||||
referenceName: doc.name,
|
||||
amount: doc.outstandingAmount,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
getMakePaymentAction(fyo),
|
||||
getMakeStockTransferAction(fyo, schemaName),
|
||||
getLedgerLinkAction(fyo),
|
||||
];
|
||||
}
|
||||
|
||||
export function getLedgerLinkAction(fyo: Fyo): Action {
|
||||
export function getMakeStockTransferAction(
|
||||
fyo: Fyo,
|
||||
schemaName: ModelNameEnum.SalesInvoice | ModelNameEnum.PurchaseInvoice
|
||||
): Action {
|
||||
let label = fyo.t`Shipment`;
|
||||
if (schemaName === ModelNameEnum.PurchaseInvoice) {
|
||||
label = fyo.t`Purchase Receipt`;
|
||||
}
|
||||
|
||||
return {
|
||||
label: fyo.t`Ledger Entries`,
|
||||
label,
|
||||
group: fyo.t`Create`,
|
||||
condition: (doc: Doc) => doc.isSubmitted && !!doc.stockNotTransferred,
|
||||
action: async (doc: Doc) => {
|
||||
const transfer = await (doc as Invoice).getStockTransfer();
|
||||
if (!transfer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { routeTo } = await import('src/utils/ui');
|
||||
const path = `/edit/${transfer.schemaName}/${transfer.name}`;
|
||||
await routeTo(path);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getMakePaymentAction(fyo: Fyo): Action {
|
||||
return {
|
||||
label: fyo.t`Payment`,
|
||||
group: fyo.t`Create`,
|
||||
condition: (doc: Doc) =>
|
||||
doc.isSubmitted && !(doc.outstandingAmount as Money).isZero(),
|
||||
action: async (doc: Doc) => {
|
||||
const payment = (doc as Invoice).getPayment();
|
||||
if (!payment) {
|
||||
return;
|
||||
}
|
||||
|
||||
payment.once('afterSync', async () => {
|
||||
await payment.submit();
|
||||
});
|
||||
|
||||
const { openQuickEdit } = await import('src/utils/ui');
|
||||
await openQuickEdit({
|
||||
doc: payment,
|
||||
hideFields: ['party', 'paymentType', 'for'],
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getLedgerLinkAction(
|
||||
fyo: Fyo,
|
||||
isStock: boolean = false
|
||||
): Action {
|
||||
let label = fyo.t`Accounting Entries`;
|
||||
let reportClassName = 'GeneralLedger';
|
||||
|
||||
if (isStock) {
|
||||
label = fyo.t`Stock Entries`;
|
||||
reportClassName = 'StockLedger';
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
group: fyo.t`View`,
|
||||
condition: (doc: Doc) => doc.isSubmitted,
|
||||
action: async (doc: Doc, router: Router) => {
|
||||
router.push({
|
||||
name: 'Report',
|
||||
params: {
|
||||
reportClassName: 'GeneralLedger',
|
||||
reportClassName,
|
||||
defaultFilters: JSON.stringify({
|
||||
referenceType: doc.schemaName,
|
||||
referenceName: doc.name,
|
||||
@ -80,8 +110,6 @@ export function getLedgerLinkAction(fyo: Fyo): Action {
|
||||
}
|
||||
|
||||
export function getTransactionStatusColumn(): ColumnConfig {
|
||||
const statusMap = getStatusMap();
|
||||
|
||||
return {
|
||||
label: t`Status`,
|
||||
fieldname: 'status',
|
||||
@ -89,7 +117,7 @@ export function getTransactionStatusColumn(): ColumnConfig {
|
||||
render(doc) {
|
||||
const status = getDocStatus(doc) as InvoiceStatus;
|
||||
const color = statusColor[status];
|
||||
const label = statusMap[status];
|
||||
const label = getStatusText(status);
|
||||
|
||||
return {
|
||||
template: `<Badge class="text-xs" color="${color}">${label}</Badge>`,
|
||||
@ -112,17 +140,25 @@ export const statusColor: Record<
|
||||
Cancelled: 'red',
|
||||
};
|
||||
|
||||
export function getStatusMap(): Record<DocStatus | InvoiceStatus, string> {
|
||||
return {
|
||||
'': '',
|
||||
Draft: t`Draft`,
|
||||
Unpaid: t`Unpaid`,
|
||||
Paid: t`Paid`,
|
||||
Saved: t`Saved`,
|
||||
NotSaved: t`Not Saved`,
|
||||
Submitted: t`Submitted`,
|
||||
Cancelled: t`Cancelled`,
|
||||
};
|
||||
export function getStatusText(status: DocStatus | InvoiceStatus): string {
|
||||
switch (status) {
|
||||
case 'Draft':
|
||||
return t`Draft`;
|
||||
case 'Saved':
|
||||
return t`Saved`;
|
||||
case 'NotSaved':
|
||||
return t`NotSaved`;
|
||||
case 'Submitted':
|
||||
return t`Submitted`;
|
||||
case 'Cancelled':
|
||||
return t`Cancelled`;
|
||||
case 'Paid':
|
||||
return t`Paid`;
|
||||
case 'Unpaid':
|
||||
return t`Unpaid`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function getDocStatus(
|
||||
@ -267,3 +303,21 @@ export function getNumberSeries(schemaName: string, fyo: Fyo) {
|
||||
const value = defaults?.[numberSeriesKey] as string | undefined;
|
||||
return value ?? (field?.default as string | undefined);
|
||||
}
|
||||
|
||||
export function getDocStatusListColumn(): ColumnConfig {
|
||||
return {
|
||||
label: t`Status`,
|
||||
fieldname: 'status',
|
||||
fieldtype: 'Select',
|
||||
size: 'small',
|
||||
render(doc) {
|
||||
const status = getDocStatus(doc);
|
||||
const color = statusColor[status] ?? 'gray';
|
||||
const label = getStatusText(status);
|
||||
|
||||
return {
|
||||
template: `<Badge class="text-xs" color="${color}">${label}</Badge>`,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -17,6 +17,15 @@ import { SalesInvoiceItem } from './baseModels/SalesInvoiceItem/SalesInvoiceItem
|
||||
import { SetupWizard } from './baseModels/SetupWizard/SetupWizard';
|
||||
import { Tax } from './baseModels/Tax/Tax';
|
||||
import { TaxSummary } from './baseModels/TaxSummary/TaxSummary';
|
||||
import { InventorySettings } from './inventory/InventorySettings';
|
||||
import { Location } from './inventory/Location';
|
||||
import { PurchaseReceipt } from './inventory/PurchaseReceipt';
|
||||
import { PurchaseReceiptItem } from './inventory/PurchaseReceiptItem';
|
||||
import { Shipment } from './inventory/Shipment';
|
||||
import { ShipmentItem } from './inventory/ShipmentItem';
|
||||
import { StockLedgerEntry } from './inventory/StockLedgerEntry';
|
||||
import { StockMovement } from './inventory/StockMovement';
|
||||
import { StockMovementItem } from './inventory/StockMovementItem';
|
||||
|
||||
export const models = {
|
||||
Account,
|
||||
@ -37,6 +46,16 @@ export const models = {
|
||||
SetupWizard,
|
||||
Tax,
|
||||
TaxSummary,
|
||||
// Inventory Models
|
||||
InventorySettings,
|
||||
StockMovement,
|
||||
StockMovementItem,
|
||||
StockLedgerEntry,
|
||||
Location,
|
||||
Shipment,
|
||||
ShipmentItem,
|
||||
PurchaseReceipt,
|
||||
PurchaseReceiptItem,
|
||||
} as ModelMap;
|
||||
|
||||
export async function getRegionalModels(
|
||||
|
27
models/inventory/InventorySettings.ts
Normal file
27
models/inventory/InventorySettings.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { Doc } from 'fyo/model/doc';
|
||||
import { FiltersMap } from 'fyo/model/types';
|
||||
import { AccountTypeEnum } from 'models/baseModels/Account/types';
|
||||
import { ValuationMethod } from './types';
|
||||
|
||||
export class InventorySettings extends Doc {
|
||||
defaultLocation?: string;
|
||||
stockInHand?: string;
|
||||
valuationMethod?: ValuationMethod;
|
||||
stockReceivedButNotBilled?: string;
|
||||
costOfGoodsSold?: string;
|
||||
|
||||
static filters: FiltersMap = {
|
||||
stockInHand: () => ({
|
||||
isGroup: false,
|
||||
accountType: AccountTypeEnum.Stock,
|
||||
}),
|
||||
stockReceivedButNotBilled: () => ({
|
||||
isGroup: false,
|
||||
accountType: AccountTypeEnum['Stock Received But Not Billed'],
|
||||
}),
|
||||
costOfGoodsSold: () => ({
|
||||
isGroup: false,
|
||||
accountType: AccountTypeEnum['Cost of Goods Sold'],
|
||||
}),
|
||||
};
|
||||
}
|
5
models/inventory/Location.ts
Normal file
5
models/inventory/Location.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Doc } from 'fyo/model/doc';
|
||||
|
||||
export class Location extends Doc {
|
||||
item?: string;
|
||||
}
|
21
models/inventory/PurchaseReceipt.ts
Normal file
21
models/inventory/PurchaseReceipt.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { ListViewSettings } from 'fyo/model/types';
|
||||
import { getTransactionStatusColumn } from 'models/helpers';
|
||||
import { PurchaseReceiptItem } from './PurchaseReceiptItem';
|
||||
import { StockTransfer } from './StockTransfer';
|
||||
|
||||
export class PurchaseReceipt extends StockTransfer {
|
||||
items?: PurchaseReceiptItem[];
|
||||
|
||||
static getListViewSettings(): ListViewSettings {
|
||||
return {
|
||||
formRoute: (name) => `/edit/PurchaseReceipt/${name}`,
|
||||
columns: [
|
||||
'name',
|
||||
getTransactionStatusColumn(),
|
||||
'party',
|
||||
'date',
|
||||
'grandTotal',
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
3
models/inventory/PurchaseReceiptItem.ts
Normal file
3
models/inventory/PurchaseReceiptItem.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { StockTransferItem } from './StockTransferItem';
|
||||
|
||||
export class PurchaseReceiptItem extends StockTransferItem {}
|
21
models/inventory/Shipment.ts
Normal file
21
models/inventory/Shipment.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { ListViewSettings } from 'fyo/model/types';
|
||||
import { getTransactionStatusColumn } from 'models/helpers';
|
||||
import { ShipmentItem } from './ShipmentItem';
|
||||
import { StockTransfer } from './StockTransfer';
|
||||
|
||||
export class Shipment extends StockTransfer {
|
||||
items?: ShipmentItem[];
|
||||
|
||||
static getListViewSettings(): ListViewSettings {
|
||||
return {
|
||||
formRoute: (name) => `/edit/Shipment/${name}`,
|
||||
columns: [
|
||||
'name',
|
||||
getTransactionStatusColumn(),
|
||||
'party',
|
||||
'date',
|
||||
'grandTotal',
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
3
models/inventory/ShipmentItem.ts
Normal file
3
models/inventory/ShipmentItem.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { StockTransferItem } from './StockTransferItem';
|
||||
|
||||
export class ShipmentItem extends StockTransferItem {}
|
13
models/inventory/StockLedgerEntry.ts
Normal file
13
models/inventory/StockLedgerEntry.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Doc } from 'fyo/model/doc';
|
||||
import { Money } from 'pesa';
|
||||
|
||||
|
||||
export class StockLedgerEntry extends Doc {
|
||||
date?: Date;
|
||||
item?: string;
|
||||
rate?: Money;
|
||||
quantity?: number;
|
||||
location?: string;
|
||||
referenceName?: string;
|
||||
referenceType?: string;
|
||||
}
|
283
models/inventory/StockManager.ts
Normal file
283
models/inventory/StockManager.ts
Normal file
@ -0,0 +1,283 @@
|
||||
import { Fyo, t } from 'fyo';
|
||||
import { ValidationError } from 'fyo/utils/errors';
|
||||
import { DateTime } from 'luxon';
|
||||
import { ModelNameEnum } from 'models/types';
|
||||
import { Money } from 'pesa';
|
||||
import { StockLedgerEntry } from './StockLedgerEntry';
|
||||
import { SMDetails, SMIDetails, SMTransferDetails } from './types';
|
||||
|
||||
export class StockManager {
|
||||
/**
|
||||
* The Stock Manager manages a group of Stock Manager Items
|
||||
* all of which would belong to a single transaction such as a
|
||||
* single Stock Movement entry.
|
||||
*/
|
||||
|
||||
items: StockManagerItem[];
|
||||
details: SMDetails;
|
||||
|
||||
isCancelled: boolean;
|
||||
fyo: Fyo;
|
||||
|
||||
constructor(details: SMDetails, isCancelled: boolean, fyo: Fyo) {
|
||||
this.items = [];
|
||||
this.details = details;
|
||||
this.isCancelled = isCancelled;
|
||||
this.fyo = fyo;
|
||||
}
|
||||
|
||||
async validateTransfers(transferDetails: SMTransferDetails[]) {
|
||||
const detailsList = transferDetails.map((d) => this.#getSMIDetails(d));
|
||||
for (const details of detailsList) {
|
||||
await this.#validate(details);
|
||||
}
|
||||
}
|
||||
|
||||
async createTransfers(transferDetails: SMTransferDetails[]) {
|
||||
const detailsList = transferDetails.map((d) => this.#getSMIDetails(d));
|
||||
for (const details of detailsList) {
|
||||
await this.#validate(details);
|
||||
}
|
||||
|
||||
for (const details of detailsList) {
|
||||
await this.#createTransfer(details);
|
||||
}
|
||||
|
||||
await this.#sync();
|
||||
}
|
||||
|
||||
async cancelTransfers() {
|
||||
const { referenceName, referenceType } = this.details;
|
||||
await this.fyo.db.deleteAll(ModelNameEnum.StockLedgerEntry, {
|
||||
referenceType,
|
||||
referenceName,
|
||||
});
|
||||
}
|
||||
|
||||
async validateCancel(transferDetails: SMTransferDetails[]) {
|
||||
const reverseTransferDetails = transferDetails.map(
|
||||
({ item, rate, quantity, fromLocation, toLocation }) => ({
|
||||
item,
|
||||
rate,
|
||||
quantity,
|
||||
fromLocation: toLocation,
|
||||
toLocation: fromLocation,
|
||||
})
|
||||
);
|
||||
await this.validateTransfers(reverseTransferDetails);
|
||||
}
|
||||
|
||||
async #sync() {
|
||||
for (const item of this.items) {
|
||||
await item.sync();
|
||||
}
|
||||
}
|
||||
|
||||
async #createTransfer(details: SMIDetails) {
|
||||
const item = new StockManagerItem(details, this.fyo);
|
||||
item.transferStock();
|
||||
this.items.push(item);
|
||||
}
|
||||
|
||||
#getSMIDetails(transferDetails: SMTransferDetails): SMIDetails {
|
||||
return Object.assign({}, this.details, transferDetails);
|
||||
}
|
||||
|
||||
async #validate(details: SMIDetails) {
|
||||
this.#validateRate(details);
|
||||
this.#validateQuantity(details);
|
||||
this.#validateLocation(details);
|
||||
await this.#validateStockAvailability(details);
|
||||
}
|
||||
|
||||
#validateQuantity(details: SMIDetails) {
|
||||
if (!details.quantity) {
|
||||
throw new ValidationError(t`Quantity needs to be set`);
|
||||
}
|
||||
|
||||
if (details.quantity <= 0) {
|
||||
throw new ValidationError(
|
||||
t`Quantity (${details.quantity}) has to be greater than zero`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#validateRate(details: SMIDetails) {
|
||||
if (!details.rate) {
|
||||
throw new ValidationError(t`Rate needs to be set`);
|
||||
}
|
||||
|
||||
if (details.rate.lte(0)) {
|
||||
throw new ValidationError(
|
||||
t`Rate (${details.rate.float}) has to be greater than zero`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#validateLocation(details: SMIDetails) {
|
||||
if (details.fromLocation) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (details.toLocation) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new ValidationError(t`Both From and To Location cannot be undefined`);
|
||||
}
|
||||
|
||||
async #validateStockAvailability(details: SMIDetails) {
|
||||
if (!details.fromLocation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const date = details.date.toISOString();
|
||||
let quantityBefore =
|
||||
(await this.fyo.db.getStockQuantity(
|
||||
details.item,
|
||||
details.fromLocation,
|
||||
undefined,
|
||||
date
|
||||
)) ?? 0;
|
||||
|
||||
const formattedDate = this.fyo.format(details.date, 'Datetime');
|
||||
|
||||
if (this.isCancelled) {
|
||||
quantityBefore += details.quantity;
|
||||
}
|
||||
|
||||
if (quantityBefore < details.quantity) {
|
||||
throw new ValidationError(
|
||||
[
|
||||
t`Insufficient Quantity.`,
|
||||
t`Additional quantity (${
|
||||
details.quantity - quantityBefore
|
||||
}) required to make outward transfer of item ${details.item} from ${
|
||||
details.fromLocation
|
||||
} on ${formattedDate}`,
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
const quantityAfter = await this.fyo.db.getStockQuantity(
|
||||
details.item,
|
||||
details.fromLocation,
|
||||
details.date.toISOString()
|
||||
);
|
||||
if (quantityAfter === null) {
|
||||
// No future transactions
|
||||
return;
|
||||
}
|
||||
|
||||
const quantityRemaining = quantityBefore - details.quantity;
|
||||
if (quantityAfter < quantityRemaining) {
|
||||
throw new ValidationError(
|
||||
[
|
||||
t`Insufficient Quantity.`,
|
||||
t`Transfer will cause future entries to have negative stock.`,
|
||||
t`Additional quantity (${
|
||||
quantityAfter - quantityRemaining
|
||||
}) required to make outward transfer of item ${details.item} from ${
|
||||
details.fromLocation
|
||||
} on ${formattedDate}`,
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class StockManagerItem {
|
||||
/**
|
||||
* The Stock Manager Item is used to move stock to and from a location. It
|
||||
* updates the Stock Queue and creates Stock Ledger Entries.
|
||||
*
|
||||
* 1. Get existing stock Queue
|
||||
* 5. Create Stock Ledger Entry
|
||||
* 7. Insert Stock Ledger Entry
|
||||
*/
|
||||
|
||||
date: Date;
|
||||
item: string;
|
||||
rate: Money;
|
||||
quantity: number;
|
||||
referenceName: string;
|
||||
referenceType: string;
|
||||
fromLocation?: string;
|
||||
toLocation?: string;
|
||||
|
||||
stockLedgerEntries?: StockLedgerEntry[];
|
||||
|
||||
fyo: Fyo;
|
||||
|
||||
constructor(details: SMIDetails, fyo: Fyo) {
|
||||
this.date = details.date;
|
||||
this.item = details.item;
|
||||
this.rate = details.rate;
|
||||
this.quantity = details.quantity;
|
||||
this.fromLocation = details.fromLocation;
|
||||
this.toLocation = details.toLocation;
|
||||
this.referenceName = details.referenceName;
|
||||
this.referenceType = details.referenceType;
|
||||
|
||||
this.fyo = fyo;
|
||||
}
|
||||
|
||||
transferStock() {
|
||||
this.#clear();
|
||||
this.#moveStockForBothLocations();
|
||||
}
|
||||
|
||||
async sync() {
|
||||
const sles = [
|
||||
this.stockLedgerEntries?.filter((s) => s.quantity! <= 0),
|
||||
this.stockLedgerEntries?.filter((s) => s.quantity! > 0),
|
||||
]
|
||||
.flat()
|
||||
.filter(Boolean);
|
||||
|
||||
for (const sle of sles) {
|
||||
await sle!.sync();
|
||||
}
|
||||
}
|
||||
|
||||
#moveStockForBothLocations() {
|
||||
if (this.fromLocation) {
|
||||
this.#moveStockForSingleLocation(this.fromLocation, true);
|
||||
}
|
||||
|
||||
if (this.toLocation) {
|
||||
this.#moveStockForSingleLocation(this.toLocation, false);
|
||||
}
|
||||
}
|
||||
|
||||
#moveStockForSingleLocation(location: string, isOutward: boolean) {
|
||||
let quantity = this.quantity!;
|
||||
if (quantity === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOutward) {
|
||||
quantity = -quantity;
|
||||
}
|
||||
|
||||
// Stock Ledger Entry
|
||||
const stockLedgerEntry = this.#getStockLedgerEntry(location, quantity);
|
||||
this.stockLedgerEntries?.push(stockLedgerEntry);
|
||||
}
|
||||
|
||||
#getStockLedgerEntry(location: string, quantity: number) {
|
||||
return this.fyo.doc.getNewDoc(ModelNameEnum.StockLedgerEntry, {
|
||||
date: this.date,
|
||||
item: this.item,
|
||||
rate: this.rate,
|
||||
quantity,
|
||||
location,
|
||||
referenceName: this.referenceName,
|
||||
referenceType: this.referenceType,
|
||||
}) as StockLedgerEntry;
|
||||
}
|
||||
|
||||
#clear() {
|
||||
this.stockLedgerEntries = [];
|
||||
}
|
||||
}
|
95
models/inventory/StockMovement.ts
Normal file
95
models/inventory/StockMovement.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { Fyo } from 'fyo';
|
||||
import {
|
||||
Action,
|
||||
DefaultMap,
|
||||
FiltersMap,
|
||||
FormulaMap,
|
||||
ListViewSettings,
|
||||
} from 'fyo/model/types';
|
||||
import { getDocStatusListColumn, getLedgerLinkAction } from 'models/helpers';
|
||||
import { LedgerPosting } from 'models/Transactional/LedgerPosting';
|
||||
import { ModelNameEnum } from 'models/types';
|
||||
import { Money } from 'pesa';
|
||||
import { StockMovementItem } from './StockMovementItem';
|
||||
import { Transfer } from './Transfer';
|
||||
import { MovementType } from './types';
|
||||
|
||||
export class StockMovement extends Transfer {
|
||||
name?: string;
|
||||
date?: Date;
|
||||
numberSeries?: string;
|
||||
movementType?: MovementType;
|
||||
items?: StockMovementItem[];
|
||||
amount?: Money;
|
||||
|
||||
override get isTransactional(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override async getPosting(): Promise<LedgerPosting | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
formulas: FormulaMap = {
|
||||
amount: {
|
||||
formula: () => {
|
||||
return this.items?.reduce(
|
||||
(acc, item) => acc.add(item.amount ?? 0),
|
||||
this.fyo.pesa(0)
|
||||
);
|
||||
},
|
||||
dependsOn: ['items'],
|
||||
},
|
||||
};
|
||||
|
||||
static filters: FiltersMap = {
|
||||
numberSeries: () => ({ referenceType: ModelNameEnum.StockMovement }),
|
||||
};
|
||||
|
||||
static defaults: DefaultMap = {
|
||||
date: () => new Date(),
|
||||
};
|
||||
|
||||
static getListViewSettings(fyo: Fyo): ListViewSettings {
|
||||
return {
|
||||
columns: [
|
||||
'name',
|
||||
getDocStatusListColumn(),
|
||||
'date',
|
||||
{
|
||||
label: fyo.t`Movement Type`,
|
||||
fieldname: 'movementType',
|
||||
fieldtype: 'Select',
|
||||
size: 'small',
|
||||
render(doc) {
|
||||
const movementType = doc.movementType as MovementType;
|
||||
const label =
|
||||
{
|
||||
[MovementType.MaterialIssue]: fyo.t`Material Issue`,
|
||||
[MovementType.MaterialReceipt]: fyo.t`Material Receipt`,
|
||||
[MovementType.MaterialTransfer]: fyo.t`Material Transfer`,
|
||||
}[movementType] ?? '';
|
||||
|
||||
return {
|
||||
template: `<span>${label}</span>`,
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
_getTransferDetails() {
|
||||
return (this.items ?? []).map((row) => ({
|
||||
item: row.item!,
|
||||
rate: row.rate!,
|
||||
quantity: row.quantity!,
|
||||
fromLocation: row.fromLocation,
|
||||
toLocation: row.toLocation,
|
||||
}));
|
||||
}
|
||||
|
||||
static getActions(fyo: Fyo): Action[] {
|
||||
return [getLedgerLinkAction(fyo, true)];
|
||||
}
|
||||
}
|
101
models/inventory/StockMovementItem.ts
Normal file
101
models/inventory/StockMovementItem.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { Doc } from 'fyo/model/doc';
|
||||
import {
|
||||
FiltersMap,
|
||||
FormulaMap,
|
||||
ReadOnlyMap,
|
||||
RequiredMap,
|
||||
} from 'fyo/model/types';
|
||||
import { ModelNameEnum } from 'models/types';
|
||||
import { Money } from 'pesa';
|
||||
import { StockMovement } from './StockMovement';
|
||||
import { MovementType } from './types';
|
||||
|
||||
export class StockMovementItem extends Doc {
|
||||
name?: string;
|
||||
item?: string;
|
||||
fromLocation?: string;
|
||||
toLocation?: string;
|
||||
quantity?: number;
|
||||
rate?: Money;
|
||||
amount?: Money;
|
||||
parentdoc?: StockMovement;
|
||||
|
||||
get isIssue() {
|
||||
return this.parentdoc?.movementType === MovementType.MaterialIssue;
|
||||
}
|
||||
|
||||
get isReceipt() {
|
||||
return this.parentdoc?.movementType === MovementType.MaterialReceipt;
|
||||
}
|
||||
|
||||
get isTransfer() {
|
||||
return this.parentdoc?.movementType === MovementType.MaterialTransfer;
|
||||
}
|
||||
|
||||
static filters: FiltersMap = {
|
||||
item: () => ({ trackItem: true }),
|
||||
};
|
||||
|
||||
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: (fn) => {
|
||||
if (this.isReceipt || this.isTransfer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const defaultLocation = this.fyo.singles.InventorySettings
|
||||
?.defaultLocation as string | undefined;
|
||||
if (defaultLocation && !this.location && this.isIssue) {
|
||||
return defaultLocation;
|
||||
}
|
||||
|
||||
return this.toLocation;
|
||||
},
|
||||
dependsOn: ['movementType'],
|
||||
},
|
||||
toLocation: {
|
||||
formula: (fn) => {
|
||||
if (this.isIssue || this.isTransfer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const defaultLocation = this.fyo.singles.InventorySettings
|
||||
?.defaultLocation as string | undefined;
|
||||
if (defaultLocation && !this.location && this.isReceipt) {
|
||||
return defaultLocation;
|
||||
}
|
||||
|
||||
return this.toLocation;
|
||||
},
|
||||
dependsOn: ['movementType'],
|
||||
},
|
||||
};
|
||||
|
||||
required: RequiredMap = {
|
||||
fromLocation: () => this.isIssue || this.isTransfer,
|
||||
toLocation: () => this.isReceipt || this.isTransfer,
|
||||
};
|
||||
|
||||
readOnly: ReadOnlyMap = {
|
||||
fromLocation: () => this.isReceipt,
|
||||
toLocation: () => this.isIssue,
|
||||
};
|
||||
|
||||
static createFilters: FiltersMap = {
|
||||
item: () => ({ trackItem: true, itemType: 'Product' }),
|
||||
};
|
||||
}
|
235
models/inventory/StockTransfer.ts
Normal file
235
models/inventory/StockTransfer.ts
Normal file
@ -0,0 +1,235 @@
|
||||
import { Fyo, t } from 'fyo';
|
||||
import { Attachment } from 'fyo/core/types';
|
||||
import { Doc } from 'fyo/model/doc';
|
||||
import { Action, DefaultMap, FiltersMap, FormulaMap } from 'fyo/model/types';
|
||||
import { ValidationError } from 'fyo/utils/errors';
|
||||
import { Defaults } from 'models/baseModels/Defaults/Defaults';
|
||||
import { Invoice } from 'models/baseModels/Invoice/Invoice';
|
||||
import { getLedgerLinkAction, getNumberSeries } from 'models/helpers';
|
||||
import { LedgerPosting } from 'models/Transactional/LedgerPosting';
|
||||
import { ModelNameEnum } from 'models/types';
|
||||
import { Money } from 'pesa';
|
||||
import { StockTransferItem } from './StockTransferItem';
|
||||
import { Transfer } from './Transfer';
|
||||
|
||||
export abstract class StockTransfer extends Transfer {
|
||||
name?: string;
|
||||
date?: Date;
|
||||
party?: string;
|
||||
terms?: string;
|
||||
attachment?: Attachment;
|
||||
grandTotal?: Money;
|
||||
backReference?: string;
|
||||
items?: StockTransferItem[];
|
||||
|
||||
get isSales() {
|
||||
return this.schemaName === ModelNameEnum.Shipment;
|
||||
}
|
||||
|
||||
formulas: FormulaMap = {
|
||||
grandTotal: {
|
||||
formula: () => this.getSum('items', 'amount', false),
|
||||
dependsOn: ['items'],
|
||||
},
|
||||
};
|
||||
|
||||
static defaults: DefaultMap = {
|
||||
numberSeries: (doc) => getNumberSeries(doc.schemaName, doc.fyo),
|
||||
terms: (doc) => {
|
||||
const defaults = doc.fyo.singles.Defaults as Defaults | undefined;
|
||||
if (doc.schemaName === ModelNameEnum.Shipment) {
|
||||
return defaults?.shipmentTerms ?? '';
|
||||
}
|
||||
|
||||
return defaults?.purchaseReceiptTerms ?? '';
|
||||
},
|
||||
date: () => new Date().toISOString().slice(0, 10),
|
||||
};
|
||||
|
||||
static filters: FiltersMap = {
|
||||
party: (doc: Doc) => ({
|
||||
role: ['in', [doc.isSales ? 'Customer' : 'Supplier', 'Both']],
|
||||
}),
|
||||
numberSeries: (doc: Doc) => ({ referenceType: doc.schemaName }),
|
||||
};
|
||||
|
||||
override _getTransferDetails() {
|
||||
return (this.items ?? []).map((row) => {
|
||||
let fromLocation = undefined;
|
||||
let toLocation = undefined;
|
||||
|
||||
if (this.isSales) {
|
||||
fromLocation = row.location;
|
||||
} else {
|
||||
toLocation = row.location;
|
||||
}
|
||||
|
||||
return {
|
||||
item: row.item!,
|
||||
rate: row.rate!,
|
||||
quantity: row.quantity!,
|
||||
fromLocation,
|
||||
toLocation,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
override async getPosting(): Promise<LedgerPosting | null> {
|
||||
await this.validateAccounts();
|
||||
const stockInHand = (await this.fyo.getValue(
|
||||
ModelNameEnum.InventorySettings,
|
||||
'stockInHand'
|
||||
)) as string;
|
||||
|
||||
const amount = this.grandTotal ?? this.fyo.pesa(0);
|
||||
const posting = new LedgerPosting(this, this.fyo);
|
||||
|
||||
if (this.isSales) {
|
||||
const costOfGoodsSold = (await this.fyo.getValue(
|
||||
ModelNameEnum.InventorySettings,
|
||||
'costOfGoodsSold'
|
||||
)) as string;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
await posting.makeRoundOffEntry();
|
||||
return posting;
|
||||
}
|
||||
|
||||
async validateAccounts() {
|
||||
const settings: string[] = ['stockInHand'];
|
||||
if (this.isSales) {
|
||||
settings.push('costOfGoodsSold');
|
||||
} else {
|
||||
settings.push('stockReceivedButNotBilled');
|
||||
}
|
||||
|
||||
const messages: string[] = [];
|
||||
for (const setting of settings) {
|
||||
const value = this.fyo.singles.InventorySettings?.[setting] as
|
||||
| string
|
||||
| undefined;
|
||||
const field = this.fyo.getField(ModelNameEnum.InventorySettings, setting);
|
||||
if (!value) {
|
||||
messages.push(t`${field.label} account not set in Inventory Settings.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const exists = await this.fyo.db.exists(ModelNameEnum.Account, value);
|
||||
if (!exists) {
|
||||
messages.push(t`Account ${value} does not exist.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.length) {
|
||||
throw new ValidationError(messages.join(' '));
|
||||
}
|
||||
}
|
||||
|
||||
static getActions(fyo: Fyo): Action[] {
|
||||
return [getLedgerLinkAction(fyo, false), getLedgerLinkAction(fyo, true)];
|
||||
}
|
||||
|
||||
async afterSubmit() {
|
||||
await super.afterSubmit();
|
||||
await this._updateBackReference();
|
||||
}
|
||||
|
||||
async afterCancel(): Promise<void> {
|
||||
await super.afterCancel();
|
||||
await this._updateBackReference();
|
||||
}
|
||||
|
||||
async _updateBackReference() {
|
||||
if (!this.isCancelled && !this.isSubmitted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.backReference) {
|
||||
return;
|
||||
}
|
||||
|
||||
const schemaName = this.isSales
|
||||
? ModelNameEnum.SalesInvoice
|
||||
: ModelNameEnum.PurchaseInvoice;
|
||||
|
||||
const invoice = (await this.fyo.doc.getDoc(
|
||||
schemaName,
|
||||
this.backReference
|
||||
)) as Invoice;
|
||||
const transferMap = this._getTransferMap();
|
||||
|
||||
for (const row of invoice.items ?? []) {
|
||||
const item = row.item!;
|
||||
const quantity = row.quantity!;
|
||||
const notTransferred = (row.stockNotTransferred as number) ?? 0;
|
||||
|
||||
const transferred = transferMap[item];
|
||||
if (
|
||||
typeof transferred !== 'number' ||
|
||||
typeof notTransferred !== 'number'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.isCancelled) {
|
||||
await row.set(
|
||||
'stockNotTransferred',
|
||||
Math.min(notTransferred + transferred, quantity)
|
||||
);
|
||||
transferMap[item] = Math.max(
|
||||
transferred + notTransferred - quantity,
|
||||
0
|
||||
);
|
||||
} else {
|
||||
await row.set(
|
||||
'stockNotTransferred',
|
||||
Math.max(notTransferred - transferred, 0)
|
||||
);
|
||||
transferMap[item] = Math.max(transferred - notTransferred, 0);
|
||||
}
|
||||
}
|
||||
|
||||
const notTransferred = invoice.getStockNotTransferred();
|
||||
await invoice.setAndSync('stockNotTransferred', notTransferred);
|
||||
}
|
||||
|
||||
_getTransferMap() {
|
||||
return (this.items ?? []).reduce((acc, item) => {
|
||||
if (!item.item) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (!item.quantity) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc[item.item] ??= 0;
|
||||
acc[item.item] += item.quantity;
|
||||
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
}
|
||||
|
||||
override duplicate(): Doc {
|
||||
const doc = super.duplicate() as StockTransfer;
|
||||
doc.backReference = undefined;
|
||||
return doc;
|
||||
}
|
||||
|
||||
static createFilters: FiltersMap = {
|
||||
party: (doc: Doc) => ({
|
||||
role: doc.isSales ? 'Customer' : 'Supplier',
|
||||
}),
|
||||
};
|
||||
}
|
122
models/inventory/StockTransferItem.ts
Normal file
122
models/inventory/StockTransferItem.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { Doc } from 'fyo/model/doc';
|
||||
import { FiltersMap, FormulaMap } from 'fyo/model/types';
|
||||
import { ModelNameEnum } from 'models/types';
|
||||
import { Money } from 'pesa';
|
||||
|
||||
export class StockTransferItem extends Doc {
|
||||
item?: string;
|
||||
location?: string;
|
||||
quantity?: number;
|
||||
rate?: Money;
|
||||
amount?: Money;
|
||||
unit?: string;
|
||||
description?: string;
|
||||
hsnCode?: number;
|
||||
|
||||
formulas: FormulaMap = {
|
||||
description: {
|
||||
formula: async () =>
|
||||
(await this.fyo.getValue(
|
||||
'Item',
|
||||
this.item as string,
|
||||
'description'
|
||||
)) as string,
|
||||
dependsOn: ['item'],
|
||||
},
|
||||
unit: {
|
||||
formula: async () =>
|
||||
(await this.fyo.getValue(
|
||||
'Item',
|
||||
this.item as string,
|
||||
'unit'
|
||||
)) as string,
|
||||
dependsOn: ['item'],
|
||||
},
|
||||
hsnCode: {
|
||||
formula: async () =>
|
||||
(await this.fyo.getValue(
|
||||
'Item',
|
||||
this.item as string,
|
||||
'hsnCode'
|
||||
)) as string,
|
||||
dependsOn: ['item'],
|
||||
},
|
||||
amount: {
|
||||
formula: () => {
|
||||
return this.rate?.mul(this.quantity ?? 0) ?? this.fyo.pesa(0);
|
||||
},
|
||||
dependsOn: ['rate', 'quantity'],
|
||||
},
|
||||
rate: {
|
||||
formula: async (fieldname) => {
|
||||
const rate = (await this.fyo.getValue(
|
||||
'Item',
|
||||
this.item as string,
|
||||
'rate'
|
||||
)) as undefined | Money;
|
||||
|
||||
if (!rate?.float && this.rate?.float) {
|
||||
return this.rate;
|
||||
}
|
||||
|
||||
return rate ?? this.fyo.pesa(0);
|
||||
},
|
||||
dependsOn: ['item'],
|
||||
},
|
||||
quantity: {
|
||||
formula: async () => {
|
||||
if (!this.item) {
|
||||
return this.quantity as number;
|
||||
}
|
||||
|
||||
const itemDoc = await this.fyo.doc.getDoc(
|
||||
ModelNameEnum.Item,
|
||||
this.item as string
|
||||
);
|
||||
|
||||
const unitDoc = itemDoc.getLink('unit');
|
||||
if (unitDoc?.isWhole) {
|
||||
return Math.round(this.quantity as number);
|
||||
}
|
||||
|
||||
return this.quantity as number;
|
||||
},
|
||||
dependsOn: ['quantity'],
|
||||
},
|
||||
account: {
|
||||
formula: () => {
|
||||
let accountType = 'expenseAccount';
|
||||
if (this.isSales) {
|
||||
accountType = 'incomeAccount';
|
||||
}
|
||||
return this.fyo.getValue('Item', this.item as string, accountType);
|
||||
},
|
||||
dependsOn: ['item'],
|
||||
},
|
||||
location: {
|
||||
formula: () => {
|
||||
if (this.location) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultLocation = this.fyo.singles.InventorySettings
|
||||
?.defaultLocation as string | undefined;
|
||||
|
||||
if (defaultLocation && !this.location) {
|
||||
return defaultLocation;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
static filters: FiltersMap = {
|
||||
item: (doc: Doc) => {
|
||||
let itemNotFor = 'Sales';
|
||||
if (doc.isSales) {
|
||||
itemNotFor = 'Purchases';
|
||||
}
|
||||
|
||||
return { for: ['not in', [itemNotFor]], trackItem: true };
|
||||
},
|
||||
};
|
||||
}
|
51
models/inventory/Transfer.ts
Normal file
51
models/inventory/Transfer.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { Transactional } from 'models/Transactional/Transactional';
|
||||
import { StockManager } from './StockManager';
|
||||
import { SMTransferDetails } from './types';
|
||||
|
||||
export abstract class Transfer extends Transactional {
|
||||
date?: Date;
|
||||
|
||||
async beforeSubmit(): Promise<void> {
|
||||
await super.beforeSubmit();
|
||||
const transferDetails = this._getTransferDetails();
|
||||
await this._getStockManager().validateTransfers(transferDetails);
|
||||
}
|
||||
|
||||
async afterSubmit(): Promise<void> {
|
||||
await super.afterSubmit();
|
||||
const transferDetails = this._getTransferDetails();
|
||||
await this._getStockManager().createTransfers(transferDetails);
|
||||
}
|
||||
|
||||
async beforeCancel(): Promise<void> {
|
||||
await super.beforeCancel();
|
||||
const transferDetails = this._getTransferDetails();
|
||||
const stockManager = this._getStockManager();
|
||||
stockManager.isCancelled = true;
|
||||
await stockManager.validateCancel(transferDetails);
|
||||
}
|
||||
|
||||
async afterCancel(): Promise<void> {
|
||||
await super.afterCancel();
|
||||
await this._getStockManager().cancelTransfers();
|
||||
}
|
||||
|
||||
_getStockManager(): StockManager {
|
||||
let date = this.date!;
|
||||
if (typeof date === 'string') {
|
||||
date = new Date(date);
|
||||
}
|
||||
|
||||
return new StockManager(
|
||||
{
|
||||
date,
|
||||
referenceName: this.name!,
|
||||
referenceType: this.schemaName,
|
||||
},
|
||||
this.isCancelled,
|
||||
this.fyo
|
||||
);
|
||||
}
|
||||
|
||||
abstract _getTransferDetails(): SMTransferDetails[];
|
||||
}
|
1
models/inventory/helpers.ts
Normal file
1
models/inventory/helpers.ts
Normal file
@ -0,0 +1 @@
|
||||
|
88
models/inventory/stockQueue.ts
Normal file
88
models/inventory/stockQueue.ts
Normal file
@ -0,0 +1,88 @@
|
||||
export class StockQueue {
|
||||
quantity: number;
|
||||
value: number;
|
||||
queue: { rate: number; quantity: number }[];
|
||||
movingAverage: number;
|
||||
|
||||
constructor() {
|
||||
this.value = 0;
|
||||
this.quantity = 0;
|
||||
this.movingAverage = 0;
|
||||
this.queue = [];
|
||||
}
|
||||
|
||||
get fifo() {
|
||||
/**
|
||||
* Stock value maintained is based on the stock queue
|
||||
* ∴ FIFO by default. This returns FIFO valuation rate.
|
||||
*/
|
||||
const valuation = this.value / this.quantity;
|
||||
if (Number.isNaN(valuation)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return valuation;
|
||||
}
|
||||
|
||||
inward(rate: number, quantity: number): null | number {
|
||||
if (quantity <= 0 || rate < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inwardValue = rate * quantity;
|
||||
/**
|
||||
* Update Moving Average valuation
|
||||
*/
|
||||
this.movingAverage =
|
||||
(this.movingAverage * this.quantity + inwardValue) /
|
||||
(this.quantity + quantity);
|
||||
|
||||
this.quantity += quantity;
|
||||
this.value += inwardValue;
|
||||
|
||||
const last = this.queue.at(-1);
|
||||
if (last?.rate !== rate) {
|
||||
this.queue.push({ rate, quantity });
|
||||
} else {
|
||||
last.quantity += quantity;
|
||||
}
|
||||
|
||||
return rate;
|
||||
}
|
||||
|
||||
outward(quantity: number): null | number {
|
||||
if (this.quantity < quantity || quantity <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let incomingRate: number = 0;
|
||||
this.quantity -= quantity;
|
||||
let remaining = quantity;
|
||||
|
||||
while (remaining > 0) {
|
||||
const last = this.queue.shift();
|
||||
if (last === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storedQuantity = last.quantity;
|
||||
let quantityRemoved = remaining;
|
||||
|
||||
const quantityLeft = storedQuantity - remaining;
|
||||
remaining = remaining - storedQuantity;
|
||||
|
||||
if (remaining > 0) {
|
||||
quantityRemoved = storedQuantity;
|
||||
}
|
||||
|
||||
if (quantityLeft > 0) {
|
||||
this.queue.unshift({ rate: last.rate, quantity: quantityLeft });
|
||||
}
|
||||
|
||||
this.value -= last.rate * quantityRemoved;
|
||||
incomingRate += quantityRemoved * last.rate;
|
||||
}
|
||||
|
||||
return incomingRate / quantity;
|
||||
}
|
||||
}
|
105
models/inventory/tests/helpers.ts
Normal file
105
models/inventory/tests/helpers.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { Fyo } from 'fyo';
|
||||
import { ModelNameEnum } from 'models/types';
|
||||
import { StockMovement } from '../StockMovement';
|
||||
import { StockTransfer } from '../StockTransfer';
|
||||
import { MovementType } from '../types';
|
||||
|
||||
type ALE = {
|
||||
date: string;
|
||||
account: string;
|
||||
party: string;
|
||||
debit: string;
|
||||
credit: string;
|
||||
reverted: number;
|
||||
};
|
||||
|
||||
type SLE = {
|
||||
date: string;
|
||||
name: string;
|
||||
item: string;
|
||||
location: string;
|
||||
rate: string;
|
||||
quantity: string;
|
||||
};
|
||||
|
||||
type Transfer = {
|
||||
item: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
quantity: number;
|
||||
rate: number;
|
||||
};
|
||||
|
||||
interface TransferTwo extends Omit<Transfer, 'from' | 'to'> {
|
||||
location: string;
|
||||
}
|
||||
|
||||
export function getItem(name: string, rate: number) {
|
||||
return { name, rate, trackItem: true };
|
||||
}
|
||||
|
||||
export async function getStockTransfer(
|
||||
schemaName: ModelNameEnum.PurchaseReceipt | ModelNameEnum.Shipment,
|
||||
party: string,
|
||||
date: Date,
|
||||
transfers: TransferTwo[],
|
||||
fyo: Fyo
|
||||
): Promise<StockTransfer> {
|
||||
const doc = fyo.doc.getNewDoc(schemaName, { party, date }) as StockTransfer;
|
||||
for (const { item, location, quantity, rate } of transfers) {
|
||||
await doc.append('items', { item, location, quantity, rate });
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
export async function getStockMovement(
|
||||
movementType: MovementType,
|
||||
date: Date,
|
||||
transfers: Transfer[],
|
||||
fyo: Fyo
|
||||
): Promise<StockMovement> {
|
||||
const doc = fyo.doc.getNewDoc(ModelNameEnum.StockMovement, {
|
||||
movementType,
|
||||
date,
|
||||
}) as StockMovement;
|
||||
|
||||
for (const {
|
||||
item,
|
||||
from: fromLocation,
|
||||
to: toLocation,
|
||||
quantity,
|
||||
rate,
|
||||
} of transfers) {
|
||||
await doc.append('items', {
|
||||
item,
|
||||
fromLocation,
|
||||
toLocation,
|
||||
rate,
|
||||
quantity,
|
||||
});
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
export async function getSLEs(
|
||||
referenceName: string,
|
||||
referenceType: string,
|
||||
fyo: Fyo
|
||||
) {
|
||||
return (await fyo.db.getAllRaw(ModelNameEnum.StockLedgerEntry, {
|
||||
filters: { referenceName, referenceType },
|
||||
fields: ['date', 'name', 'item', 'location', 'rate', 'quantity'],
|
||||
})) as SLE[];
|
||||
}
|
||||
|
||||
export async function getALEs(
|
||||
referenceName: string,
|
||||
referenceType: string,
|
||||
fyo: Fyo
|
||||
) {
|
||||
return (await fyo.db.getAllRaw(ModelNameEnum.AccountingLedgerEntry, {
|
||||
filters: { referenceName, referenceType },
|
||||
fields: ['date', 'account', 'party', 'debit', 'credit', 'reverted'],
|
||||
})) as ALE[];
|
||||
}
|
374
models/inventory/tests/testInventory.spec.ts
Normal file
374
models/inventory/tests/testInventory.spec.ts
Normal file
@ -0,0 +1,374 @@
|
||||
import {
|
||||
assertDoesNotThrow,
|
||||
assertThrows
|
||||
} from 'backend/database/tests/helpers';
|
||||
import { ModelNameEnum } from 'models/types';
|
||||
import { default as tape, default as test } from 'tape';
|
||||
import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers';
|
||||
import { StockMovement } from '../StockMovement';
|
||||
import { MovementType } from '../types';
|
||||
import { getItem, getSLEs, getStockMovement } from './helpers';
|
||||
|
||||
const fyo = getTestFyo();
|
||||
|
||||
setupTestFyo(fyo, __filename);
|
||||
|
||||
const itemMap = {
|
||||
Pen: {
|
||||
name: 'Pen',
|
||||
rate: 700,
|
||||
},
|
||||
Ink: {
|
||||
name: 'Ink',
|
||||
rate: 50,
|
||||
},
|
||||
};
|
||||
|
||||
const locationMap = {
|
||||
LocationOne: 'LocationOne',
|
||||
LocationTwo: 'LocationTwo',
|
||||
};
|
||||
|
||||
/**
|
||||
* Section 1: Test Creation of Items and Locations
|
||||
*/
|
||||
|
||||
test('create dummy items & locations', async (t) => {
|
||||
// Create Items
|
||||
for (const { name, rate } of Object.values(itemMap)) {
|
||||
const item = getItem(name, rate);
|
||||
await fyo.doc.getNewDoc(ModelNameEnum.Item, item).sync();
|
||||
t.ok(await fyo.db.exists(ModelNameEnum.Item, name), `${name} exists`);
|
||||
}
|
||||
|
||||
// Create Locations
|
||||
for (const name of Object.values(locationMap)) {
|
||||
await fyo.doc.getNewDoc(ModelNameEnum.Location, { name }).sync();
|
||||
t.ok(await fyo.db.exists(ModelNameEnum.Location, name), `${name} exists`);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Section 2: Test Creation of Stock Movements
|
||||
*/
|
||||
|
||||
test('create stock movement, material receipt', async (t) => {
|
||||
const { rate } = itemMap.Ink;
|
||||
const quantity = 2;
|
||||
const amount = rate * quantity;
|
||||
const stockMovement = await getStockMovement(
|
||||
MovementType.MaterialReceipt,
|
||||
new Date('2022-11-03T09:57:04.528'),
|
||||
[
|
||||
{
|
||||
item: itemMap.Ink.name,
|
||||
to: locationMap.LocationOne,
|
||||
quantity,
|
||||
rate,
|
||||
},
|
||||
],
|
||||
fyo
|
||||
);
|
||||
|
||||
await (await stockMovement.sync()).submit();
|
||||
t.ok(stockMovement.name?.startsWith('SMOV-'));
|
||||
t.equal(stockMovement.amount?.float, amount);
|
||||
t.equal(stockMovement.items?.[0].amount?.float, amount);
|
||||
|
||||
const name = stockMovement.name!;
|
||||
|
||||
const sles = await getSLEs(name, ModelNameEnum.StockMovement, fyo);
|
||||
t.equal(sles.length, 1);
|
||||
|
||||
const sle = sles[0];
|
||||
t.notEqual(new Date(sle.date).toString(), 'Invalid Date');
|
||||
t.equal(parseInt(sle.name), 1);
|
||||
t.equal(sle.item, itemMap.Ink.name);
|
||||
t.equal(parseFloat(sle.rate), rate);
|
||||
t.equal(sle.quantity, quantity);
|
||||
t.equal(sle.location, locationMap.LocationOne);
|
||||
t.equal(await fyo.db.getStockQuantity(itemMap.Ink.name), quantity);
|
||||
});
|
||||
|
||||
test('create stock movement, material transfer', async (t) => {
|
||||
const { rate } = itemMap.Ink;
|
||||
const quantity = 2;
|
||||
|
||||
const stockMovement = await getStockMovement(
|
||||
MovementType.MaterialTransfer,
|
||||
new Date('2022-11-03T09:58:04.528'),
|
||||
[
|
||||
{
|
||||
item: itemMap.Ink.name,
|
||||
from: locationMap.LocationOne,
|
||||
to: locationMap.LocationTwo,
|
||||
quantity,
|
||||
rate,
|
||||
},
|
||||
],
|
||||
fyo
|
||||
);
|
||||
|
||||
await (await stockMovement.sync()).submit();
|
||||
const name = stockMovement.name!;
|
||||
|
||||
const sles = await getSLEs(name, ModelNameEnum.StockMovement, fyo);
|
||||
t.equal(sles.length, 2);
|
||||
|
||||
for (const sle of sles) {
|
||||
t.notEqual(new Date(sle.date).toString(), 'Invalid Date');
|
||||
t.equal(sle.item, itemMap.Ink.name);
|
||||
t.equal(parseFloat(sle.rate), rate);
|
||||
|
||||
if (sle.location === locationMap.LocationOne) {
|
||||
t.equal(sle.quantity, -quantity);
|
||||
} else if (sle.location === locationMap.LocationTwo) {
|
||||
t.equal(sle.quantity, quantity);
|
||||
} else {
|
||||
t.ok(false, 'no-op');
|
||||
}
|
||||
}
|
||||
|
||||
t.equal(
|
||||
await fyo.db.getStockQuantity(itemMap.Ink.name, locationMap.LocationOne),
|
||||
0
|
||||
);
|
||||
t.equal(await fyo.db.getStockQuantity(itemMap.Ink.name), quantity);
|
||||
});
|
||||
|
||||
test('create stock movement, material issue', async (t) => {
|
||||
const { rate } = itemMap.Ink;
|
||||
const quantity = 2;
|
||||
|
||||
const stockMovement = await getStockMovement(
|
||||
MovementType.MaterialIssue,
|
||||
new Date('2022-11-03T09:59:04.528'),
|
||||
[
|
||||
{
|
||||
item: itemMap.Ink.name,
|
||||
from: locationMap.LocationTwo,
|
||||
quantity,
|
||||
rate,
|
||||
},
|
||||
],
|
||||
fyo
|
||||
);
|
||||
|
||||
await (await stockMovement.sync()).submit();
|
||||
const name = stockMovement.name!;
|
||||
|
||||
const sles = await getSLEs(name, ModelNameEnum.StockMovement, fyo);
|
||||
t.equal(sles.length, 1);
|
||||
|
||||
const sle = sles[0];
|
||||
t.notEqual(new Date(sle.date).toString(), 'Invalid Date');
|
||||
t.equal(sle.item, itemMap.Ink.name);
|
||||
t.equal(parseFloat(sle.rate), rate);
|
||||
t.equal(sle.quantity, -quantity);
|
||||
t.equal(sle.location, locationMap.LocationTwo);
|
||||
t.equal(await fyo.db.getStockQuantity(itemMap.Ink.name), 0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Section 3: Test Cancellation of Stock Movements
|
||||
*/
|
||||
|
||||
test('cancel stock movement', async (t) => {
|
||||
const names = (await fyo.db.getAllRaw(ModelNameEnum.StockMovement)) as {
|
||||
name: string;
|
||||
}[];
|
||||
|
||||
for (const { name } of names) {
|
||||
const slesBefore = await getSLEs(name, ModelNameEnum.StockMovement, fyo);
|
||||
const doc = (await fyo.doc.getDoc(
|
||||
ModelNameEnum.StockMovement,
|
||||
name
|
||||
)) as StockMovement;
|
||||
|
||||
if (doc.movementType === MovementType.MaterialTransfer) {
|
||||
t.equal(slesBefore.length, (doc.items?.length ?? 0) * 2);
|
||||
} else {
|
||||
t.equal(slesBefore.length, doc.items?.length ?? 0);
|
||||
}
|
||||
|
||||
await doc.cancel();
|
||||
const slesAfter = await getSLEs(name, ModelNameEnum.StockMovement, fyo);
|
||||
t.equal(slesAfter.length, 0);
|
||||
}
|
||||
|
||||
t.equal(await fyo.db.getStockQuantity(itemMap.Ink.name), null);
|
||||
});
|
||||
|
||||
/**
|
||||
* Section 4: Test Invalid entries
|
||||
*/
|
||||
|
||||
async function runEntries(
|
||||
item: string,
|
||||
entries: {
|
||||
type: MovementType;
|
||||
date: Date;
|
||||
valid: boolean;
|
||||
postQuantity: number;
|
||||
items: {
|
||||
item: string;
|
||||
to?: string;
|
||||
from?: string;
|
||||
quantity: number;
|
||||
rate: number;
|
||||
}[];
|
||||
}[],
|
||||
t: tape.Test
|
||||
) {
|
||||
for (const { type, date, items, valid, postQuantity } of entries) {
|
||||
const stockMovement = await getStockMovement(type, date, items, fyo);
|
||||
await stockMovement.sync();
|
||||
|
||||
if (valid) {
|
||||
await assertDoesNotThrow(async () => await stockMovement.submit());
|
||||
} else {
|
||||
await assertThrows(async () => await stockMovement.submit());
|
||||
}
|
||||
|
||||
t.equal(await fyo.db.getStockQuantity(item), postQuantity);
|
||||
}
|
||||
}
|
||||
|
||||
test('create stock movements, invalid entries, in sequence', async (t) => {
|
||||
const { name: item, rate } = itemMap.Ink;
|
||||
const quantity = 10;
|
||||
await runEntries(
|
||||
item,
|
||||
[
|
||||
{
|
||||
type: MovementType.MaterialReceipt,
|
||||
date: new Date('2022-11-03T09:58:04.528'),
|
||||
valid: true,
|
||||
postQuantity: quantity,
|
||||
items: [
|
||||
{
|
||||
item,
|
||||
to: locationMap.LocationOne,
|
||||
quantity,
|
||||
rate,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: MovementType.MaterialTransfer,
|
||||
date: new Date('2022-11-03T09:58:05.528'),
|
||||
valid: false,
|
||||
postQuantity: quantity,
|
||||
items: [
|
||||
{
|
||||
item,
|
||||
from: locationMap.LocationOne,
|
||||
to: locationMap.LocationTwo,
|
||||
quantity: quantity + 1,
|
||||
rate,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: MovementType.MaterialIssue,
|
||||
date: new Date('2022-11-03T09:58:06.528'),
|
||||
valid: false,
|
||||
postQuantity: quantity,
|
||||
items: [
|
||||
{
|
||||
item,
|
||||
from: locationMap.LocationOne,
|
||||
quantity: quantity + 1,
|
||||
rate,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: MovementType.MaterialTransfer,
|
||||
date: new Date('2022-11-03T09:58:07.528'),
|
||||
valid: true,
|
||||
postQuantity: quantity,
|
||||
items: [
|
||||
{
|
||||
item,
|
||||
from: locationMap.LocationOne,
|
||||
to: locationMap.LocationTwo,
|
||||
quantity,
|
||||
rate,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: MovementType.MaterialIssue,
|
||||
date: new Date('2022-11-03T09:58:08.528'),
|
||||
valid: true,
|
||||
postQuantity: 0,
|
||||
items: [
|
||||
{
|
||||
item,
|
||||
from: locationMap.LocationTwo,
|
||||
quantity,
|
||||
rate,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
t
|
||||
);
|
||||
});
|
||||
|
||||
test('create stock movements, invalid entries, out of sequence', async (t) => {
|
||||
const { name: item, rate } = itemMap.Ink;
|
||||
const quantity = 10;
|
||||
await runEntries(
|
||||
item,
|
||||
[
|
||||
{
|
||||
type: MovementType.MaterialReceipt,
|
||||
date: new Date('2022-11-15'),
|
||||
valid: true,
|
||||
postQuantity: quantity,
|
||||
items: [
|
||||
{
|
||||
item,
|
||||
to: locationMap.LocationOne,
|
||||
quantity,
|
||||
rate,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: MovementType.MaterialIssue,
|
||||
date: new Date('2022-11-17'),
|
||||
valid: true,
|
||||
postQuantity: quantity - 5,
|
||||
items: [
|
||||
{
|
||||
item,
|
||||
from: locationMap.LocationOne,
|
||||
quantity: quantity - 5,
|
||||
rate,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: MovementType.MaterialTransfer,
|
||||
date: new Date('2022-11-16'),
|
||||
valid: false,
|
||||
postQuantity: quantity - 5,
|
||||
items: [
|
||||
{
|
||||
item,
|
||||
from: locationMap.LocationOne,
|
||||
to: locationMap.LocationTwo,
|
||||
quantity,
|
||||
rate,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
t
|
||||
);
|
||||
});
|
||||
|
||||
closeTestFyo(fyo, __filename);
|
116
models/inventory/tests/testStockQueue.spec.ts
Normal file
116
models/inventory/tests/testStockQueue.spec.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import test from 'tape';
|
||||
import { StockQueue } from '../stockQueue';
|
||||
|
||||
test('stockQueue:initialization', (t) => {
|
||||
const q = new StockQueue();
|
||||
|
||||
t.equal(q.quantity, 0);
|
||||
t.equal(q.value, 0);
|
||||
t.equal(q.queue.length, 0);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('stockQueue:operations', (t) => {
|
||||
const q = new StockQueue();
|
||||
|
||||
t.equal(q.inward(100, 4), 100);
|
||||
t.equal(q.fifo, 100);
|
||||
t.equal(q.movingAverage, 100);
|
||||
t.equal(q.queue.length, 1);
|
||||
t.equal(q.quantity, 4);
|
||||
t.equal(q.value, 400);
|
||||
|
||||
t.equal(q.inward(200, 8), 200);
|
||||
t.equal(q.fifo, (400 + 1600) / 12);
|
||||
t.equal(q.movingAverage, (400 + 1600) / 12);
|
||||
t.equal(q.queue.length, 2);
|
||||
t.equal(q.quantity, 4 + 8);
|
||||
t.equal(q.value, 400 + 1600);
|
||||
|
||||
t.equal(q.inward(300, 3), 300);
|
||||
t.equal(q.fifo, (400 + 1600 + 900) / 15);
|
||||
t.equal(q.movingAverage, (400 + 1600 + 900) / 15);
|
||||
t.equal(q.queue.length, 3);
|
||||
t.equal(q.quantity, 4 + 8 + 3);
|
||||
t.equal(q.value, 400 + 1600 + 900);
|
||||
|
||||
t.equal(q.outward(3), 100);
|
||||
t.equal(q.fifo, (100 + 1600 + 900) / 12);
|
||||
t.equal(q.movingAverage, (400 + 1600 + 900) / 15);
|
||||
t.equal(q.queue.length, 3);
|
||||
t.equal(q.quantity, 1 + 8 + 3);
|
||||
t.equal(q.value, 100 + 1600 + 900);
|
||||
|
||||
t.equal(q.outward(5), (100 + 800) / 5);
|
||||
t.equal(q.fifo, (800 + 900) / 7);
|
||||
t.equal(q.movingAverage, (400 + 1600 + 900) / 15);
|
||||
t.equal(q.queue.length, 2);
|
||||
t.equal(q.quantity, 4 + 3);
|
||||
t.equal(q.value, 800 + 900);
|
||||
|
||||
t.equal(q.outward(4), 200);
|
||||
t.equal(q.fifo, 900 / 3);
|
||||
t.equal(q.movingAverage, (400 + 1600 + 900) / 15);
|
||||
t.equal(q.queue.length, 1);
|
||||
t.equal(q.quantity, 3);
|
||||
t.equal(q.value, 900);
|
||||
|
||||
t.equal(q.outward(3), 300);
|
||||
t.equal(q.fifo, 0);
|
||||
t.equal(q.movingAverage, (400 + 1600 + 900) / 15);
|
||||
t.equal(q.queue.length, 0);
|
||||
t.equal(q.quantity, 0);
|
||||
t.equal(q.value, 0);
|
||||
|
||||
t.equal(q.inward(100, 1), 100);
|
||||
t.equal(q.fifo, 100);
|
||||
t.equal(q.movingAverage, 100);
|
||||
t.equal(q.queue.length, 1);
|
||||
t.equal(q.quantity, 1);
|
||||
t.equal(q.value, 100);
|
||||
|
||||
t.equal(q.inward(150, 1), 150);
|
||||
t.equal(q.fifo, (100 + 150) / 2);
|
||||
t.equal(q.movingAverage, (100 + 150) / 2);
|
||||
t.equal(q.queue.length, 2);
|
||||
t.equal(q.quantity, 2);
|
||||
t.equal(q.value, 100 + 150);
|
||||
|
||||
t.equal(q.inward(100, 1), 100);
|
||||
t.equal(q.fifo, (100 + 150 + 100) / 3);
|
||||
t.equal(q.movingAverage, (100 + 150 + 100) / 3);
|
||||
t.equal(q.queue.length, 3);
|
||||
t.equal(q.quantity, 3);
|
||||
t.equal(q.value, 100 + 150 + 100);
|
||||
|
||||
t.equal(q.outward(1), 100);
|
||||
t.equal(q.fifo, (150 + 100) / 2);
|
||||
t.equal(q.movingAverage, (100 + 150 + 100) / 3);
|
||||
t.equal(q.queue.length, 2);
|
||||
t.equal(q.quantity, 2);
|
||||
t.equal(q.value, 150 + 100);
|
||||
|
||||
t.equal(q.outward(2), (150 + 100) / 2);
|
||||
t.equal(q.fifo, 0);
|
||||
t.equal(q.movingAverage, (100 + 150 + 100) / 3);
|
||||
t.equal(q.queue.length, 0);
|
||||
t.equal(q.quantity, 0);
|
||||
t.equal(q.value, 0);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('stockQueue:invalidOperations', (t) => {
|
||||
const q = new StockQueue();
|
||||
|
||||
t.equal(q.outward(1), null);
|
||||
t.equal(q.outward(0), null);
|
||||
t.equal(q.outward(-5), null);
|
||||
|
||||
t.equal(q.inward(1000, -1), null);
|
||||
t.equal(q.inward(0, 0), null);
|
||||
t.equal(q.inward(-0.1, 5), null);
|
||||
|
||||
t.end();
|
||||
});
|
539
models/inventory/tests/testStockTransfer.spec.ts
Normal file
539
models/inventory/tests/testStockTransfer.spec.ts
Normal file
@ -0,0 +1,539 @@
|
||||
import {
|
||||
assertDoesNotThrow,
|
||||
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';
|
||||
|
||||
const fyo = getTestFyo();
|
||||
setupTestFyo(fyo, __filename);
|
||||
|
||||
const item = 'Pen';
|
||||
const location = 'Common';
|
||||
const party = 'Someone';
|
||||
const testDocs = {
|
||||
Item: {
|
||||
[item]: getItem(item, 100),
|
||||
},
|
||||
Location: {
|
||||
[location]: { name: location },
|
||||
},
|
||||
Party: { [party]: { name: party, Role: 'Both' } },
|
||||
} as Record<string, Record<string, { name: string; [key: string]: RawValue }>>;
|
||||
|
||||
test('insert test docs', async (t) => {
|
||||
for (const schemaName in testDocs) {
|
||||
for (const name in testDocs[schemaName]) {
|
||||
await fyo.doc.getNewDoc(schemaName, testDocs[schemaName][name]).sync();
|
||||
}
|
||||
}
|
||||
|
||||
t.ok(await fyo.db.exists(ModelNameEnum.Party, party), 'party created');
|
||||
t.ok(
|
||||
await fyo.db.exists(ModelNameEnum.Location, location),
|
||||
'location created'
|
||||
);
|
||||
t.ok(await fyo.db.exists(ModelNameEnum.Item, item), 'item created');
|
||||
});
|
||||
|
||||
test('inventory settings', async (t) => {
|
||||
const doc = (await fyo.doc.getDoc(
|
||||
ModelNameEnum.InventorySettings
|
||||
)) as InventorySettings;
|
||||
|
||||
t.equal(doc.valuationMethod, ValuationMethod.FIFO, 'fifo valuation set');
|
||||
t.ok(doc.stockInHand, 'stock in hand set');
|
||||
t.ok(doc.stockReceivedButNotBilled, 'stock rec. but not billed set');
|
||||
});
|
||||
|
||||
test('PurchaseReceipt, create inward stock movement', async (t) => {
|
||||
const date = new Date('2022-01-01');
|
||||
const rate = (testDocs['Item'][item].rate as number) ?? 0;
|
||||
const quantity = 10;
|
||||
const doc = await getStockTransfer(
|
||||
ModelNameEnum.PurchaseReceipt,
|
||||
party,
|
||||
date,
|
||||
[
|
||||
{
|
||||
item,
|
||||
location,
|
||||
quantity,
|
||||
rate,
|
||||
},
|
||||
],
|
||||
fyo
|
||||
);
|
||||
|
||||
await doc.sync();
|
||||
const grandTotal = quantity * rate;
|
||||
t.equal(doc.grandTotal?.float, quantity * rate);
|
||||
|
||||
await doc.submit();
|
||||
|
||||
t.equal(
|
||||
(await fyo.db.getAllRaw(ModelNameEnum.PurchaseReceipt)).length,
|
||||
1,
|
||||
'purchase receipt created'
|
||||
);
|
||||
t.equal(
|
||||
(await getSLEs(doc.name!, doc.schemaName, fyo)).length,
|
||||
1,
|
||||
'sle created'
|
||||
);
|
||||
t.equal(
|
||||
await fyo.db.getStockQuantity(item, location),
|
||||
quantity,
|
||||
'stock purchased'
|
||||
);
|
||||
t.ok(doc.name?.startsWith('PREC-'));
|
||||
|
||||
const ales = await getALEs(doc.name!, doc.schemaName, fyo);
|
||||
for (const ale of ales) {
|
||||
t.equal(ale.party, party, 'party matches');
|
||||
if (ale.account === 'Stock Received But Not Billed') {
|
||||
t.equal(parseFloat(ale.debit), 0);
|
||||
t.equal(parseFloat(ale.credit), grandTotal);
|
||||
} else {
|
||||
t.equal(parseFloat(ale.credit), 0);
|
||||
t.equal(parseFloat(ale.debit), grandTotal);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Shipment, create outward stock movement', async (t) => {
|
||||
const date = new Date('2022-01-02');
|
||||
const rate = (testDocs['Item'][item].rate as number) ?? 0;
|
||||
const quantity = 5;
|
||||
const doc = await getStockTransfer(
|
||||
ModelNameEnum.Shipment,
|
||||
party,
|
||||
date,
|
||||
[
|
||||
{
|
||||
item,
|
||||
location,
|
||||
quantity,
|
||||
rate,
|
||||
},
|
||||
],
|
||||
fyo
|
||||
);
|
||||
|
||||
await doc.sync();
|
||||
const grandTotal = quantity * rate;
|
||||
t.equal(doc.grandTotal?.float, grandTotal);
|
||||
|
||||
await doc.submit();
|
||||
|
||||
t.equal(
|
||||
(await fyo.db.getAllRaw(ModelNameEnum.Shipment)).length,
|
||||
1,
|
||||
'shipment created'
|
||||
);
|
||||
t.equal(
|
||||
(await getSLEs(doc.name!, doc.schemaName, fyo)).length,
|
||||
1,
|
||||
'sle created'
|
||||
);
|
||||
t.equal(
|
||||
await fyo.db.getStockQuantity(item, location),
|
||||
10 - quantity,
|
||||
'stock purchased'
|
||||
);
|
||||
t.ok(doc.name?.startsWith('SHPM-'));
|
||||
|
||||
const ales = await getALEs(doc.name!, doc.schemaName, fyo);
|
||||
for (const ale of ales) {
|
||||
t.equal(ale.party, party, 'party matches');
|
||||
if (ale.account === 'Cost of Goods Sold') {
|
||||
t.equal(parseFloat(ale.debit), grandTotal);
|
||||
t.equal(parseFloat(ale.credit), 0);
|
||||
} else {
|
||||
t.equal(parseFloat(ale.debit), 0);
|
||||
t.equal(parseFloat(ale.credit), grandTotal);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Shipment, invalid', async (t) => {
|
||||
const date = new Date('2022-01-03');
|
||||
const rate = (testDocs['Item'][item].rate as number) ?? 0;
|
||||
const quantity = 10;
|
||||
const doc = await getStockTransfer(
|
||||
ModelNameEnum.Shipment,
|
||||
party,
|
||||
date,
|
||||
[
|
||||
{
|
||||
item,
|
||||
location,
|
||||
quantity,
|
||||
rate,
|
||||
},
|
||||
],
|
||||
fyo
|
||||
);
|
||||
|
||||
await doc.sync();
|
||||
const grandTotal = quantity * rate;
|
||||
|
||||
t.equal(await fyo.db.getStockQuantity(item, location), 5, 'stock unchanged');
|
||||
t.equal(doc.grandTotal?.float, grandTotal);
|
||||
await assertThrows(async () => await doc.submit());
|
||||
|
||||
t.equal(
|
||||
(await getSLEs(doc.name!, doc.schemaName, fyo)).length,
|
||||
0,
|
||||
'sles not created'
|
||||
);
|
||||
t.equal(
|
||||
(await getALEs(doc.name!, doc.schemaName, fyo)).length,
|
||||
0,
|
||||
'ales not created'
|
||||
);
|
||||
});
|
||||
|
||||
test('Stock Transfer, invalid cancellation', async (t) => {
|
||||
const { name } =
|
||||
(
|
||||
(await fyo.db.getAllRaw(ModelNameEnum.PurchaseReceipt)) as {
|
||||
name: string;
|
||||
}[]
|
||||
)[0] ?? {};
|
||||
|
||||
t.ok(name?.startsWith('PREC-'));
|
||||
const doc = await fyo.doc.getDoc(ModelNameEnum.PurchaseReceipt, name);
|
||||
await assertThrows(async () => await doc.cancel());
|
||||
t.equal(await fyo.db.getStockQuantity(item, location), 5, 'stock unchanged');
|
||||
t.equal(
|
||||
(await getSLEs(name, doc.schemaName, fyo)).length,
|
||||
1,
|
||||
'sle unchanged'
|
||||
);
|
||||
const ales = await getALEs(name, doc.schemaName, fyo);
|
||||
t.ok(ales.every((i) => !i.reverted) && ales.length === 2, 'ale unchanged');
|
||||
});
|
||||
|
||||
test('Shipment, cancel and delete', async (t) => {
|
||||
const { name } =
|
||||
(
|
||||
(await fyo.db.getAllRaw(ModelNameEnum.Shipment, { order: 'asc' })) as {
|
||||
name: string;
|
||||
}[]
|
||||
)[0] ?? {};
|
||||
|
||||
t.ok(name?.startsWith('SHPM-'), 'number series matches');
|
||||
const doc = await fyo.doc.getDoc(ModelNameEnum.Shipment, name);
|
||||
t.ok(doc.isSubmitted, `doc ${name} is submitted`);
|
||||
await assertDoesNotThrow(async () => await doc.cancel());
|
||||
t.ok(doc.isCancelled), `doc is cancelled`;
|
||||
|
||||
t.equal(await fyo.db.getStockQuantity(item, location), 10, 'stock changed');
|
||||
t.equal((await getSLEs(name, doc.schemaName, fyo)).length, 0, 'sle deleted');
|
||||
const ales = await getALEs(name, doc.schemaName, fyo);
|
||||
t.ok(ales.every((i) => !!i.reverted) && ales.length === 4, 'ale reverted');
|
||||
|
||||
await doc.delete();
|
||||
t.equal((await getALEs(name, doc.schemaName, fyo)).length, 0, 'ales deleted');
|
||||
t.equal(
|
||||
(
|
||||
await fyo.db.getAllRaw(ModelNameEnum.Shipment, {
|
||||
filters: { name: name },
|
||||
})
|
||||
).length,
|
||||
0,
|
||||
'doc deleted'
|
||||
);
|
||||
});
|
||||
|
||||
test('Purchase Receipt, cancel and delete', async (t) => {
|
||||
const { name } =
|
||||
(
|
||||
(await fyo.db.getAllRaw(ModelNameEnum.PurchaseReceipt, {
|
||||
order: 'asc',
|
||||
})) as {
|
||||
name: string;
|
||||
}[]
|
||||
)[0] ?? {};
|
||||
|
||||
t.ok(name?.startsWith('PREC-'), 'number series matches');
|
||||
const doc = await fyo.doc.getDoc(ModelNameEnum.PurchaseReceipt, name);
|
||||
t.ok(doc.isSubmitted, `doc ${name} is submitted`);
|
||||
await assertDoesNotThrow(async () => await doc.cancel());
|
||||
t.ok(doc.isCancelled), `doc is cancelled`;
|
||||
|
||||
t.equal(await fyo.db.getStockQuantity(item, location), null, 'stock changed');
|
||||
t.equal((await getSLEs(name, doc.schemaName, fyo)).length, 0, 'sle deleted');
|
||||
const ales = await getALEs(name, doc.schemaName, fyo);
|
||||
t.ok(ales.every((i) => !!i.reverted) && ales.length === 4, 'ale reverted');
|
||||
|
||||
await doc.delete();
|
||||
t.equal((await getALEs(name, doc.schemaName, fyo)).length, 0, 'ales deleted');
|
||||
t.equal(
|
||||
(
|
||||
await fyo.db.getAllRaw(ModelNameEnum.Shipment, {
|
||||
filters: { name: name },
|
||||
})
|
||||
).length,
|
||||
0,
|
||||
'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);
|
28
models/inventory/types.ts
Normal file
28
models/inventory/types.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { Money } from 'pesa';
|
||||
|
||||
export enum ValuationMethod {
|
||||
'FIFO' = 'FIFO',
|
||||
'MovingAverage' = 'MovingAverage',
|
||||
}
|
||||
|
||||
export enum MovementType {
|
||||
'MaterialIssue' = 'MaterialIssue',
|
||||
'MaterialReceipt' = 'MaterialReceipt',
|
||||
'MaterialTransfer' = 'MaterialTransfer',
|
||||
}
|
||||
|
||||
export interface SMDetails {
|
||||
date: Date;
|
||||
referenceName: string;
|
||||
referenceType: string;
|
||||
}
|
||||
|
||||
export interface SMTransferDetails {
|
||||
item: string;
|
||||
rate: Money;
|
||||
quantity: number;
|
||||
fromLocation?: string;
|
||||
toLocation?: string;
|
||||
}
|
||||
|
||||
export interface SMIDetails extends SMDetails, SMTransferDetails {}
|
@ -29,7 +29,16 @@ export enum ModelNameEnum {
|
||||
TaxSummary = 'TaxSummary',
|
||||
PatchRun = 'PatchRun',
|
||||
SingleValue = 'SingleValue',
|
||||
InventorySettings = 'InventorySettings',
|
||||
SystemSettings = 'SystemSettings',
|
||||
StockMovement = 'StockMovement',
|
||||
StockMovementItem = 'StockMovementItem',
|
||||
StockLedgerEntry = 'StockLedgerEntry',
|
||||
Shipment = 'Shipment',
|
||||
ShipmentItem = 'ShipmentItem',
|
||||
PurchaseReceipt = 'PurchaseReceipt',
|
||||
PurchaseReceiptItem = 'PurchaseReceiptItem',
|
||||
Location = 'Location',
|
||||
}
|
||||
|
||||
export type ModelName = keyof typeof ModelNameEnum;
|
||||
|
@ -17,7 +17,7 @@
|
||||
"electron:serve": "vue-cli-service electron:serve",
|
||||
"script:translate": "scripts/runner.sh scripts/generateTranslations.ts",
|
||||
"script:profile": "scripts/profile.sh",
|
||||
"test": "scripts/runner.sh ./node_modules/.bin/tape ./**/tests/**/*.spec.ts | tap-spec"
|
||||
"test": "scripts/test.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.10.2",
|
||||
|
@ -17,6 +17,8 @@ type ReferenceType =
|
||||
| ModelNameEnum.PurchaseInvoice
|
||||
| ModelNameEnum.Payment
|
||||
| ModelNameEnum.JournalEntry
|
||||
| ModelNameEnum.Shipment
|
||||
| ModelNameEnum.PurchaseReceipt
|
||||
| 'All';
|
||||
|
||||
export class GeneralLedger extends LedgerReport {
|
||||
@ -272,17 +274,25 @@ export class GeneralLedger extends LedgerReport {
|
||||
}
|
||||
|
||||
getFilters() {
|
||||
const refTypeOptions = [
|
||||
{ label: t`All`, value: 'All' },
|
||||
{ label: t`Sales Invoices`, value: 'SalesInvoice' },
|
||||
{ label: t`Purchase Invoices`, value: 'PurchaseInvoice' },
|
||||
{ label: t`Payments`, value: 'Payment' },
|
||||
{ label: t`Journal Entries`, value: 'JournalEntry' },
|
||||
];
|
||||
|
||||
if (!this.fyo.singles.AccountingSettings?.enableInventory) {
|
||||
refTypeOptions.push(
|
||||
{ label: t`Shipment`, value: 'Shipment' },
|
||||
{ label: t`Purchase Receipt`, value: 'PurchaseReceipt' }
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
fieldtype: 'Select',
|
||||
options: [
|
||||
{ label: t`All`, value: 'All' },
|
||||
{ label: t`Sales Invoices`, value: 'SalesInvoice' },
|
||||
{ label: t`Purchase Invoices`, value: 'PurchaseInvoice' },
|
||||
{ label: t`Payments`, value: 'Payment' },
|
||||
{ label: t`Journal Entries`, value: 'JournalEntry' },
|
||||
],
|
||||
|
||||
options: refTypeOptions,
|
||||
label: t`Ref Type`,
|
||||
fieldname: 'referenceType',
|
||||
placeholder: t`Ref Type`,
|
||||
|
@ -8,6 +8,7 @@ import { ColumnField, ReportData } from './types';
|
||||
export abstract class Report extends Observable<RawValue> {
|
||||
static title: string;
|
||||
static reportName: string;
|
||||
static isInventory: boolean = false;
|
||||
|
||||
fyo: Fyo;
|
||||
columns: ColumnField[] = [];
|
||||
|
@ -2,6 +2,8 @@ import { BalanceSheet } from './BalanceSheet/BalanceSheet';
|
||||
import { GeneralLedger } from './GeneralLedger/GeneralLedger';
|
||||
import { GSTR1 } from './GoodsAndServiceTax/GSTR1';
|
||||
import { GSTR2 } from './GoodsAndServiceTax/GSTR2';
|
||||
import { StockLedger } from './inventory/StockLedger';
|
||||
import { StockBalance } from './inventory/StockBalance';
|
||||
import { ProfitAndLoss } from './ProfitAndLoss/ProfitAndLoss';
|
||||
import { Report } from './Report';
|
||||
import { TrialBalance } from './TrialBalance/TrialBalance';
|
||||
@ -13,4 +15,6 @@ export const reports = {
|
||||
TrialBalance,
|
||||
GSTR1,
|
||||
GSTR2,
|
||||
StockLedger,
|
||||
StockBalance,
|
||||
} as Record<string, typeof Report>;
|
||||
|
143
reports/inventory/StockBalance.ts
Normal file
143
reports/inventory/StockBalance.ts
Normal file
@ -0,0 +1,143 @@
|
||||
import { t } from 'fyo';
|
||||
import { RawValueMap } from 'fyo/core/types';
|
||||
import { Action } from 'fyo/model/types';
|
||||
import getCommonExportActions from 'reports/commonExporter';
|
||||
import { ColumnField, ReportData } from 'reports/types';
|
||||
import { Field } from 'schemas/types';
|
||||
import { getStockBalanceEntries } from './helpers';
|
||||
import { StockLedger } from './StockLedger';
|
||||
import { ReferenceType } from './types';
|
||||
|
||||
export class StockBalance extends StockLedger {
|
||||
static title = t`Stock Balance`;
|
||||
static reportName = 'stock-balance';
|
||||
static isInventory = true;
|
||||
|
||||
override ascending: boolean = true;
|
||||
override referenceType: ReferenceType = 'All';
|
||||
override referenceName: string = '';
|
||||
|
||||
override async _getReportData(force?: boolean): Promise<ReportData> {
|
||||
if (this.shouldRefresh || force || !this._rawData?.length) {
|
||||
await this._setRawData();
|
||||
}
|
||||
|
||||
const filters = {
|
||||
item: this.item,
|
||||
location: this.location,
|
||||
fromDate: this.fromDate,
|
||||
toDate: this.toDate,
|
||||
};
|
||||
const rawData = getStockBalanceEntries(this._rawData ?? [], filters);
|
||||
|
||||
return rawData.map((sbe, i) => {
|
||||
const row = { ...sbe, name: i + 1 } as RawValueMap;
|
||||
return this._convertRawDataRowToReportRow(row, {
|
||||
incomingQuantity: 'green',
|
||||
outgoingQuantity: 'red',
|
||||
balanceQuantity: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getFilters(): Field[] {
|
||||
return [
|
||||
{
|
||||
fieldtype: 'Link',
|
||||
target: 'Item',
|
||||
placeholder: t`Item`,
|
||||
label: t`Item`,
|
||||
fieldname: 'item',
|
||||
},
|
||||
{
|
||||
fieldtype: 'Link',
|
||||
target: 'Location',
|
||||
placeholder: t`Location`,
|
||||
label: t`Location`,
|
||||
fieldname: 'location',
|
||||
},
|
||||
{
|
||||
fieldtype: 'Date',
|
||||
placeholder: t`From Date`,
|
||||
label: t`From Date`,
|
||||
fieldname: 'fromDate',
|
||||
},
|
||||
{
|
||||
fieldtype: 'Date',
|
||||
placeholder: t`To Date`,
|
||||
label: t`To Date`,
|
||||
fieldname: 'toDate',
|
||||
},
|
||||
] as Field[];
|
||||
}
|
||||
|
||||
getColumns(): ColumnField[] {
|
||||
return [
|
||||
{
|
||||
fieldname: 'name',
|
||||
label: '#',
|
||||
fieldtype: 'Int',
|
||||
width: 0.5,
|
||||
},
|
||||
{
|
||||
fieldname: 'item',
|
||||
label: 'Item',
|
||||
fieldtype: 'Link',
|
||||
},
|
||||
{
|
||||
fieldname: 'location',
|
||||
label: 'Location',
|
||||
fieldtype: 'Link',
|
||||
},
|
||||
{
|
||||
fieldname: 'balanceQuantity',
|
||||
label: 'Balance Qty.',
|
||||
fieldtype: 'Float',
|
||||
},
|
||||
{
|
||||
fieldname: 'balanceValue',
|
||||
label: 'Balance Value',
|
||||
fieldtype: 'Float',
|
||||
},
|
||||
{
|
||||
fieldname: 'openingQuantity',
|
||||
label: 'Opening Qty.',
|
||||
fieldtype: 'Float',
|
||||
},
|
||||
{
|
||||
fieldname: 'openingValue',
|
||||
label: 'Opening Value',
|
||||
fieldtype: 'Float',
|
||||
},
|
||||
{
|
||||
fieldname: 'incomingQuantity',
|
||||
label: 'In Qty.',
|
||||
fieldtype: 'Float',
|
||||
},
|
||||
{
|
||||
fieldname: 'incomingValue',
|
||||
label: 'In Value',
|
||||
fieldtype: 'Currency',
|
||||
},
|
||||
{
|
||||
fieldname: 'outgoingQuantity',
|
||||
label: 'Out Qty.',
|
||||
fieldtype: 'Float',
|
||||
},
|
||||
{
|
||||
fieldname: 'outgoingValue',
|
||||
label: 'Out Value',
|
||||
fieldtype: 'Currency',
|
||||
},
|
||||
{
|
||||
fieldname: 'valuationRate',
|
||||
label: 'Valuation rate',
|
||||
fieldtype: 'Currency',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
getActions(): Action[] {
|
||||
return getCommonExportActions(this);
|
||||
}
|
||||
}
|
376
reports/inventory/StockLedger.ts
Normal file
376
reports/inventory/StockLedger.ts
Normal file
@ -0,0 +1,376 @@
|
||||
import { Fyo, t } from 'fyo';
|
||||
import { RawValueMap } from 'fyo/core/types';
|
||||
import { Action } from 'fyo/model/types';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { DateTime } from 'luxon';
|
||||
import { ValuationMethod } from 'models/inventory/types';
|
||||
import { ModelNameEnum } from 'models/types';
|
||||
import getCommonExportActions from 'reports/commonExporter';
|
||||
import { Report } from 'reports/Report';
|
||||
import { ColumnField, ReportCell, ReportData, ReportRow } from 'reports/types';
|
||||
import { Field, RawValue } from 'schemas/types';
|
||||
import { isNumeric } from 'src/utils';
|
||||
import { getRawStockLedgerEntries, getStockLedgerEntries } from './helpers';
|
||||
import { ComputedStockLedgerEntry, ReferenceType } from './types';
|
||||
|
||||
export class StockLedger extends Report {
|
||||
static title = t`Stock Ledger`;
|
||||
static reportName = 'stock-ledger';
|
||||
static isInventory = true;
|
||||
|
||||
usePagination: boolean = true;
|
||||
|
||||
_rawData?: ComputedStockLedgerEntry[];
|
||||
loading: boolean = false;
|
||||
shouldRefresh: boolean = false;
|
||||
|
||||
item?: string;
|
||||
location?: string;
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
ascending?: boolean;
|
||||
referenceType?: ReferenceType = 'All';
|
||||
referenceName?: string;
|
||||
|
||||
groupBy: 'none' | 'item' | 'location' = 'none';
|
||||
|
||||
constructor(fyo: Fyo) {
|
||||
super(fyo);
|
||||
this._setObservers();
|
||||
}
|
||||
|
||||
async setDefaultFilters() {
|
||||
if (!this.toDate) {
|
||||
this.toDate = DateTime.now().plus({ days: 1 }).toISODate();
|
||||
this.fromDate = DateTime.now().minus({ years: 1 }).toISODate();
|
||||
}
|
||||
}
|
||||
|
||||
async setReportData(
|
||||
filter?: string | undefined,
|
||||
force?: boolean | undefined
|
||||
): Promise<void> {
|
||||
this.loading = true;
|
||||
this.reportData = await this._getReportData(force);
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
async _getReportData(force?: boolean): Promise<ReportData> {
|
||||
if (this.shouldRefresh || force || !this._rawData?.length) {
|
||||
await this._setRawData();
|
||||
}
|
||||
|
||||
const rawData = cloneDeep(this._rawData);
|
||||
if (!rawData) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filtered = this._getFilteredRawData(rawData);
|
||||
const grouped = this._getGroupedRawData(filtered);
|
||||
|
||||
return grouped.map((row) =>
|
||||
this._convertRawDataRowToReportRow(row as RawValueMap, {
|
||||
quantity: null,
|
||||
valueChange: null,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async _setRawData() {
|
||||
const valuationMethod =
|
||||
(this.fyo.singles.InventorySettings?.valuationMethod as
|
||||
| ValuationMethod
|
||||
| undefined) ?? ValuationMethod.FIFO;
|
||||
|
||||
const rawSLEs = await getRawStockLedgerEntries(this.fyo);
|
||||
this._rawData = getStockLedgerEntries(rawSLEs, valuationMethod);
|
||||
}
|
||||
|
||||
_getFilteredRawData(rawData: ComputedStockLedgerEntry[]) {
|
||||
const filteredRawData: ComputedStockLedgerEntry[] = [];
|
||||
if (!rawData.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fromDate = this.fromDate ? Date.parse(this.fromDate) : null;
|
||||
const toDate = this.toDate ? Date.parse(this.toDate) : null;
|
||||
|
||||
if (!this.ascending) {
|
||||
rawData.reverse();
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
for (const idx in rawData) {
|
||||
const row = rawData[idx];
|
||||
if (this.item && row.item !== this.item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.location && row.location !== this.location) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const date = row.date.valueOf();
|
||||
if (toDate && date > toDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fromDate && date < fromDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
this.referenceType !== 'All' &&
|
||||
row.referenceType !== this.referenceType
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.referenceName && row.referenceName !== this.referenceName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
row.name = ++i;
|
||||
filteredRawData.push(row);
|
||||
}
|
||||
|
||||
return filteredRawData;
|
||||
}
|
||||
|
||||
_getGroupedRawData(rawData: ComputedStockLedgerEntry[]) {
|
||||
const groupBy = this.groupBy;
|
||||
if (groupBy === 'none') {
|
||||
return rawData;
|
||||
}
|
||||
|
||||
const groups: Map<string, ComputedStockLedgerEntry[]> = new Map();
|
||||
for (const row of rawData) {
|
||||
const key = row[groupBy];
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, []);
|
||||
}
|
||||
|
||||
groups.get(key)?.push(row);
|
||||
}
|
||||
|
||||
const groupedRawData: (ComputedStockLedgerEntry | { name: null })[] = [];
|
||||
let i = 0;
|
||||
for (const key of groups.keys()) {
|
||||
for (const row of groups.get(key) ?? []) {
|
||||
row.name = ++i;
|
||||
groupedRawData.push(row);
|
||||
}
|
||||
|
||||
groupedRawData.push({ name: null });
|
||||
}
|
||||
|
||||
if (groupedRawData.at(-1)?.name === null) {
|
||||
groupedRawData.pop();
|
||||
}
|
||||
|
||||
return groupedRawData;
|
||||
}
|
||||
|
||||
_convertRawDataRowToReportRow(
|
||||
row: RawValueMap,
|
||||
colouredMap: Record<string, 'red' | 'green' | null>
|
||||
): ReportRow {
|
||||
const cells: ReportCell[] = [];
|
||||
const columns = this.getColumns();
|
||||
|
||||
if (row.name === null) {
|
||||
return {
|
||||
isEmpty: true,
|
||||
cells: columns.map((c) => ({
|
||||
rawValue: '',
|
||||
value: '',
|
||||
width: c.width ?? 1,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
for (const col of columns) {
|
||||
const fieldname = col.fieldname as keyof ComputedStockLedgerEntry;
|
||||
const fieldtype = col.fieldtype;
|
||||
|
||||
const rawValue = row[fieldname] as RawValue;
|
||||
|
||||
let value;
|
||||
if (col.fieldname === 'referenceType' && typeof rawValue === 'string') {
|
||||
value = this.fyo.schemaMap[rawValue]?.label ?? rawValue;
|
||||
} else {
|
||||
value = this.fyo.format(rawValue, fieldtype);
|
||||
}
|
||||
|
||||
const align = isNumeric(fieldtype) ? 'right' : 'left';
|
||||
|
||||
const isColoured = fieldname in colouredMap;
|
||||
const isNumber = typeof rawValue === 'number';
|
||||
let color: 'red' | 'green' | undefined = undefined;
|
||||
|
||||
if (isColoured && colouredMap[fieldname]) {
|
||||
color = colouredMap[fieldname]!;
|
||||
} else if (isColoured && isNumber && rawValue > 0) {
|
||||
color = 'green';
|
||||
} else if (isColoured && isNumber && rawValue < 0) {
|
||||
color = 'red';
|
||||
}
|
||||
|
||||
cells.push({ rawValue, value, align, color, width: col.width });
|
||||
}
|
||||
|
||||
return { cells };
|
||||
}
|
||||
|
||||
_setObservers() {
|
||||
const listener = () => (this.shouldRefresh = true);
|
||||
|
||||
this.fyo.doc.observer.on(
|
||||
`sync:${ModelNameEnum.StockLedgerEntry}`,
|
||||
listener
|
||||
);
|
||||
|
||||
this.fyo.doc.observer.on(
|
||||
`delete:${ModelNameEnum.StockLedgerEntry}`,
|
||||
listener
|
||||
);
|
||||
}
|
||||
|
||||
getColumns(): ColumnField[] {
|
||||
return [
|
||||
{
|
||||
fieldname: 'name',
|
||||
label: '#',
|
||||
fieldtype: 'Int',
|
||||
width: 0.5,
|
||||
},
|
||||
{
|
||||
fieldname: 'date',
|
||||
label: 'Date',
|
||||
fieldtype: 'Datetime',
|
||||
width: 1.25,
|
||||
},
|
||||
{
|
||||
fieldname: 'item',
|
||||
label: 'Item',
|
||||
fieldtype: 'Link',
|
||||
},
|
||||
{
|
||||
fieldname: 'location',
|
||||
label: 'Location',
|
||||
fieldtype: 'Link',
|
||||
},
|
||||
{
|
||||
fieldname: 'quantity',
|
||||
label: 'Quantity',
|
||||
fieldtype: 'Float',
|
||||
},
|
||||
{
|
||||
fieldname: 'balanceQuantity',
|
||||
label: 'Balance Qty.',
|
||||
fieldtype: 'Float',
|
||||
},
|
||||
{
|
||||
fieldname: 'incomingRate',
|
||||
label: 'Incoming rate',
|
||||
fieldtype: 'Currency',
|
||||
},
|
||||
{
|
||||
fieldname: 'valuationRate',
|
||||
label: 'Valuation Rate',
|
||||
fieldtype: 'Currency',
|
||||
},
|
||||
{
|
||||
fieldname: 'balanceValue',
|
||||
label: 'Balance Value',
|
||||
fieldtype: 'Currency',
|
||||
},
|
||||
{
|
||||
fieldname: 'valueChange',
|
||||
label: 'Value Change',
|
||||
fieldtype: 'Currency',
|
||||
},
|
||||
{
|
||||
fieldname: 'referenceName',
|
||||
label: 'Ref. Name',
|
||||
fieldtype: 'DynamicLink',
|
||||
},
|
||||
{
|
||||
fieldname: 'referenceType',
|
||||
label: 'Ref. Type',
|
||||
fieldtype: 'Data',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
getFilters(): Field[] {
|
||||
return [
|
||||
{
|
||||
fieldtype: 'Select',
|
||||
options: [
|
||||
{ label: t`All`, value: 'All' },
|
||||
{ label: t`Stock Movements`, value: 'StockMovement' },
|
||||
{ label: t`Shipment`, value: 'Shipment' },
|
||||
{ label: t`Purchase Receipt`, value: 'PurchaseReceipt' },
|
||||
],
|
||||
label: t`Ref Type`,
|
||||
fieldname: 'referenceType',
|
||||
placeholder: t`Ref Type`,
|
||||
},
|
||||
{
|
||||
fieldtype: 'DynamicLink',
|
||||
label: t`Ref Name`,
|
||||
references: 'referenceType',
|
||||
placeholder: t`Ref Name`,
|
||||
emptyMessage: t`Change Ref Type`,
|
||||
fieldname: 'referenceName',
|
||||
},
|
||||
{
|
||||
fieldtype: 'Link',
|
||||
target: 'Item',
|
||||
placeholder: t`Item`,
|
||||
label: t`Item`,
|
||||
fieldname: 'item',
|
||||
},
|
||||
{
|
||||
fieldtype: 'Link',
|
||||
target: 'Location',
|
||||
placeholder: t`Location`,
|
||||
label: t`Location`,
|
||||
fieldname: 'location',
|
||||
},
|
||||
{
|
||||
fieldtype: 'Date',
|
||||
placeholder: t`From Date`,
|
||||
label: t`From Date`,
|
||||
fieldname: 'fromDate',
|
||||
},
|
||||
{
|
||||
fieldtype: 'Date',
|
||||
placeholder: t`To Date`,
|
||||
label: t`To Date`,
|
||||
fieldname: 'toDate',
|
||||
},
|
||||
{
|
||||
fieldtype: 'Select',
|
||||
label: t`Group By`,
|
||||
fieldname: 'groupBy',
|
||||
options: [
|
||||
{ label: t`None`, value: 'none' },
|
||||
{ label: t`Item`, value: 'item' },
|
||||
{ label: t`Location`, value: 'location' },
|
||||
{ label: t`Reference`, value: 'referenceName' },
|
||||
],
|
||||
},
|
||||
{
|
||||
fieldtype: 'Check',
|
||||
label: t`Ascending Order`,
|
||||
fieldname: 'ascending',
|
||||
},
|
||||
] as Field[];
|
||||
}
|
||||
|
||||
getActions(): Action[] {
|
||||
return getCommonExportActions(this);
|
||||
}
|
||||
}
|
199
reports/inventory/helpers.ts
Normal file
199
reports/inventory/helpers.ts
Normal file
@ -0,0 +1,199 @@
|
||||
import { Fyo } from 'fyo';
|
||||
import { StockQueue } from 'models/inventory/stockQueue';
|
||||
import { ValuationMethod } from 'models/inventory/types';
|
||||
import { ModelNameEnum } from 'models/types';
|
||||
import { safeParseFloat, safeParseInt } from 'utils/index';
|
||||
import {
|
||||
ComputedStockLedgerEntry,
|
||||
RawStockLedgerEntry,
|
||||
StockBalanceEntry,
|
||||
} from './types';
|
||||
|
||||
type Item = string;
|
||||
type Location = string;
|
||||
|
||||
export async function getRawStockLedgerEntries(fyo: Fyo) {
|
||||
const fieldnames = [
|
||||
'name',
|
||||
'date',
|
||||
'item',
|
||||
'rate',
|
||||
'quantity',
|
||||
'location',
|
||||
'referenceName',
|
||||
'referenceType',
|
||||
];
|
||||
|
||||
return (await fyo.db.getAllRaw(ModelNameEnum.StockLedgerEntry, {
|
||||
fields: fieldnames,
|
||||
orderBy: 'date',
|
||||
order: 'asc',
|
||||
})) as RawStockLedgerEntry[];
|
||||
}
|
||||
|
||||
export function getStockLedgerEntries(
|
||||
rawSLEs: RawStockLedgerEntry[],
|
||||
valuationMethod: ValuationMethod
|
||||
): ComputedStockLedgerEntry[] {
|
||||
const computedSLEs: ComputedStockLedgerEntry[] = [];
|
||||
const stockQueues: Record<Item, Record<Location, StockQueue>> = {};
|
||||
|
||||
for (const sle of rawSLEs) {
|
||||
const name = safeParseInt(sle.name);
|
||||
const date = new Date(sle.date);
|
||||
const rate = safeParseFloat(sle.rate);
|
||||
const { item, location, quantity, referenceName, referenceType } = sle;
|
||||
|
||||
if (quantity === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
stockQueues[item] ??= {};
|
||||
stockQueues[item][location] ??= new StockQueue();
|
||||
|
||||
const q = stockQueues[item][location];
|
||||
const initialValue = q.value;
|
||||
|
||||
let incomingRate: number | null;
|
||||
if (quantity > 0) {
|
||||
incomingRate = q.inward(rate, quantity);
|
||||
} else {
|
||||
incomingRate = q.outward(-quantity);
|
||||
}
|
||||
|
||||
if (incomingRate === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const balanceQuantity = q.quantity;
|
||||
let valuationRate = q.fifo;
|
||||
if (valuationMethod === ValuationMethod.MovingAverage) {
|
||||
valuationRate = q.movingAverage;
|
||||
}
|
||||
|
||||
const balanceValue = q.value;
|
||||
const valueChange = balanceValue - initialValue;
|
||||
|
||||
const csle: ComputedStockLedgerEntry = {
|
||||
name,
|
||||
date,
|
||||
|
||||
item,
|
||||
location,
|
||||
|
||||
quantity,
|
||||
balanceQuantity,
|
||||
|
||||
incomingRate,
|
||||
valuationRate,
|
||||
|
||||
balanceValue,
|
||||
valueChange,
|
||||
|
||||
referenceName,
|
||||
referenceType,
|
||||
};
|
||||
|
||||
computedSLEs.push(csle);
|
||||
}
|
||||
|
||||
return computedSLEs;
|
||||
}
|
||||
|
||||
export function getStockBalanceEntries(
|
||||
computedSLEs: ComputedStockLedgerEntry[],
|
||||
filters: {
|
||||
item?: string;
|
||||
location?: string;
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
}
|
||||
): StockBalanceEntry[] {
|
||||
const sbeMap: Record<Item, Record<Location, StockBalanceEntry>> = {};
|
||||
|
||||
const fromDate = filters.fromDate ? Date.parse(filters.fromDate) : null;
|
||||
const toDate = filters.toDate ? Date.parse(filters.toDate) : null;
|
||||
|
||||
for (const sle of computedSLEs) {
|
||||
if (filters.item && sle.item !== filters.item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (filters.location && sle.location !== filters.location) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sbeMap[sle.item] ??= {};
|
||||
sbeMap[sle.item][sle.location] ??= getSBE(sle.item, sle.location);
|
||||
const date = sle.date.valueOf();
|
||||
|
||||
if (fromDate && date < fromDate) {
|
||||
const sbe = sbeMap[sle.item][sle.location]!;
|
||||
updateOpeningBalances(sbe, sle);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (toDate && date > toDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sbe = sbeMap[sle.item][sle.location]!;
|
||||
updateCurrentBalances(sbe, sle);
|
||||
}
|
||||
|
||||
return Object.values(sbeMap)
|
||||
.map((sbes) => Object.values(sbes))
|
||||
.flat();
|
||||
}
|
||||
|
||||
function getSBE(item: string, location: string): StockBalanceEntry {
|
||||
return {
|
||||
name: 0,
|
||||
|
||||
item,
|
||||
location,
|
||||
|
||||
balanceQuantity: 0,
|
||||
balanceValue: 0,
|
||||
|
||||
openingQuantity: 0,
|
||||
openingValue: 0,
|
||||
|
||||
incomingQuantity: 0,
|
||||
incomingValue: 0,
|
||||
|
||||
outgoingQuantity: 0,
|
||||
outgoingValue: 0,
|
||||
|
||||
valuationRate: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function updateOpeningBalances(
|
||||
sbe: StockBalanceEntry,
|
||||
sle: ComputedStockLedgerEntry
|
||||
) {
|
||||
sbe.openingQuantity += sle.quantity;
|
||||
sbe.openingValue += sle.valueChange;
|
||||
|
||||
sbe.balanceQuantity += sle.quantity;
|
||||
sbe.balanceValue += sle.valueChange;
|
||||
}
|
||||
|
||||
function updateCurrentBalances(
|
||||
sbe: StockBalanceEntry,
|
||||
sle: ComputedStockLedgerEntry
|
||||
) {
|
||||
sbe.balanceQuantity += sle.quantity;
|
||||
sbe.balanceValue += sle.valueChange;
|
||||
|
||||
if (sle.quantity > 0) {
|
||||
sbe.incomingQuantity += sle.quantity;
|
||||
sbe.incomingValue += sle.valueChange;
|
||||
} else {
|
||||
sbe.outgoingQuantity -= sle.quantity;
|
||||
sbe.outgoingValue -= sle.valueChange;
|
||||
}
|
||||
|
||||
sbe.valuationRate = sle.valuationRate;
|
||||
}
|
62
reports/inventory/types.ts
Normal file
62
reports/inventory/types.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { ModelNameEnum } from "models/types";
|
||||
|
||||
export interface RawStockLedgerEntry {
|
||||
name: string;
|
||||
date: string;
|
||||
item: string;
|
||||
rate: string;
|
||||
quantity: number;
|
||||
location: string;
|
||||
referenceName: string;
|
||||
referenceType: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
export interface ComputedStockLedgerEntry{
|
||||
name: number;
|
||||
date: Date;
|
||||
|
||||
item: string;
|
||||
location:string;
|
||||
|
||||
quantity: number;
|
||||
balanceQuantity: number;
|
||||
|
||||
incomingRate: number;
|
||||
valuationRate:number;
|
||||
|
||||
balanceValue:number;
|
||||
valueChange:number;
|
||||
|
||||
referenceName: string;
|
||||
referenceType: string;
|
||||
}
|
||||
|
||||
|
||||
export interface StockBalanceEntry{
|
||||
name: number;
|
||||
|
||||
item: string;
|
||||
location:string;
|
||||
|
||||
balanceQuantity: number;
|
||||
balanceValue: number;
|
||||
|
||||
openingQuantity: number;
|
||||
openingValue:number;
|
||||
|
||||
incomingQuantity:number;
|
||||
incomingValue:number;
|
||||
|
||||
outgoingQuantity:number;
|
||||
outgoingValue:number;
|
||||
|
||||
valuationRate:number;
|
||||
}
|
||||
|
||||
export type ReferenceType =
|
||||
| ModelNameEnum.StockMovement
|
||||
| ModelNameEnum.Shipment
|
||||
| ModelNameEnum.PurchaseReceipt
|
||||
| 'All';
|
@ -70,11 +70,17 @@
|
||||
"default": false
|
||||
},
|
||||
{
|
||||
"label": "Discount Account",
|
||||
"fieldname": "discountAccount",
|
||||
"label": "Discount Account",
|
||||
"fieldtype": "Link",
|
||||
"target": "Account"
|
||||
},
|
||||
{
|
||||
"fieldname": "enableInventory",
|
||||
"label": "Enable Inventory",
|
||||
"fieldtype": "Check",
|
||||
"default": false
|
||||
},
|
||||
{
|
||||
"fieldname": "setupComplete",
|
||||
"label": "Setup Complete",
|
||||
|
@ -32,6 +32,27 @@
|
||||
"target": "NumberSeries",
|
||||
"create": true
|
||||
},
|
||||
{
|
||||
"fieldname": "stockMovementNumberSeries",
|
||||
"label": "Stock Movement Number Series",
|
||||
"fieldtype": "Link",
|
||||
"target": "NumberSeries",
|
||||
"create": true
|
||||
},
|
||||
{
|
||||
"fieldname": "shipmentNumberSeries",
|
||||
"label": "Shipment Number Series",
|
||||
"fieldtype": "Link",
|
||||
"target": "NumberSeries",
|
||||
"create": true
|
||||
},
|
||||
{
|
||||
"fieldname": "purchaseReceiptNumberSeries",
|
||||
"label": "Purchase Receipt Number Series",
|
||||
"fieldtype": "Link",
|
||||
"target": "NumberSeries",
|
||||
"create": true
|
||||
},
|
||||
{
|
||||
"fieldname": "salesInvoiceTerms",
|
||||
"label": "Sales Invoice Terms",
|
||||
@ -43,6 +64,18 @@
|
||||
"label": "Purchase Invoice Terms",
|
||||
"fieldtype": "Text",
|
||||
"target": "NumberSeries"
|
||||
},
|
||||
{
|
||||
"fieldname": "shipmentTerms",
|
||||
"label": "Shipment Terms",
|
||||
"fieldtype": "Text",
|
||||
"target": "NumberSeries"
|
||||
},
|
||||
{
|
||||
"fieldname": "purchaseReceiptTerms",
|
||||
"label": "Purchase Receipt Terms",
|
||||
"fieldtype": "Text",
|
||||
"target": "NumberSeries"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -131,6 +131,12 @@
|
||||
"placeholder": "Add attachment",
|
||||
"label": "Attachment",
|
||||
"fieldtype": "Attachment"
|
||||
},
|
||||
{
|
||||
"fieldname": "stockNotTransferred",
|
||||
"label": "Stock Not Transferred",
|
||||
"fieldtype": "Float",
|
||||
"readOnly": true
|
||||
}
|
||||
],
|
||||
"keywordFields": ["name", "party"]
|
||||
|
@ -87,6 +87,12 @@
|
||||
"label": "HSN/SAC",
|
||||
"fieldtype": "Int",
|
||||
"placeholder": "HSN/SAC Code"
|
||||
},
|
||||
{
|
||||
"fieldname": "stockNotTransferred",
|
||||
"label": "Stock Not Transferred",
|
||||
"fieldtype": "Float",
|
||||
"readOnly": true
|
||||
}
|
||||
],
|
||||
"tableFields": ["item", "tax", "quantity", "rate", "amount"],
|
||||
|
@ -66,13 +66,12 @@
|
||||
"label": "Both"
|
||||
}
|
||||
],
|
||||
"readOnly": true,
|
||||
"required": true,
|
||||
"default": "Both"
|
||||
},
|
||||
{
|
||||
"fieldname": "incomeAccount",
|
||||
"label": "Income",
|
||||
"label": "Sales Acc.",
|
||||
"fieldtype": "Link",
|
||||
"target": "Account",
|
||||
"placeholder": "Income",
|
||||
@ -81,7 +80,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "expenseAccount",
|
||||
"label": "Expense",
|
||||
"label": "Purchase Acc.",
|
||||
"fieldtype": "Link",
|
||||
"target": "Account",
|
||||
"placeholder": "Expense",
|
||||
@ -106,6 +105,12 @@
|
||||
"label": "HSN/SAC",
|
||||
"fieldtype": "Int",
|
||||
"placeholder": "HSN/SAC Code"
|
||||
},
|
||||
{
|
||||
"fieldname": "trackItem",
|
||||
"label": "Track Item",
|
||||
"fieldtype": "Check",
|
||||
"default": false
|
||||
}
|
||||
],
|
||||
"quickEditFields": [
|
||||
@ -117,7 +122,8 @@
|
||||
"description",
|
||||
"incomeAccount",
|
||||
"expenseAccount",
|
||||
"hsnCode"
|
||||
"hsnCode",
|
||||
"trackItem"
|
||||
],
|
||||
"keywordFields": ["name", "itemType", "for"]
|
||||
}
|
||||
|
@ -46,6 +46,18 @@
|
||||
{
|
||||
"value": "JournalEntry",
|
||||
"label": "Journal Entry"
|
||||
},
|
||||
{
|
||||
"value": "StockMovement",
|
||||
"label": "Stock Movement"
|
||||
},
|
||||
{
|
||||
"value": "Shipment",
|
||||
"label": "Shipment"
|
||||
},
|
||||
{
|
||||
"value": "PurchaseReceipt",
|
||||
"label": "Purchase Receipt"
|
||||
}
|
||||
],
|
||||
"default": "-",
|
||||
|
@ -34,7 +34,6 @@
|
||||
"label": "Customer"
|
||||
}
|
||||
],
|
||||
"readOnly": true,
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
|
50
schemas/app/inventory/InventorySettings.json
Normal file
50
schemas/app/inventory/InventorySettings.json
Normal file
@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "InventorySettings",
|
||||
"label": "Inventory Settings",
|
||||
"isSingle": true,
|
||||
"isChild": false,
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "valuationMethod",
|
||||
"label": "Valuation Method",
|
||||
"fieldtype": "Select",
|
||||
"options": [
|
||||
{
|
||||
"value": "FIFO",
|
||||
"label": "FIFO"
|
||||
},
|
||||
{
|
||||
"value": "MovingAverage",
|
||||
"label": "Moving Average"
|
||||
}
|
||||
],
|
||||
"default": "FIFO",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"fieldname": "defaultLocation",
|
||||
"label": "Default Location",
|
||||
"fieldtype": "Link",
|
||||
"target": "Location",
|
||||
"create": true
|
||||
},
|
||||
{
|
||||
"fieldname": "stockInHand",
|
||||
"label": "Stock In Hand Acc.",
|
||||
"fieldtype": "Link",
|
||||
"target": "Account"
|
||||
},
|
||||
{
|
||||
"fieldname": "stockReceivedButNotBilled",
|
||||
"label": "Stock Received But Not Billed Acc.",
|
||||
"fieldtype": "Link",
|
||||
"target": "Account"
|
||||
},
|
||||
{
|
||||
"fieldname": "costOfGoodsSold",
|
||||
"label": "Cost Of Goods Sold Acc.",
|
||||
"fieldtype": "Link",
|
||||
"target": "Account"
|
||||
}
|
||||
]
|
||||
}
|
24
schemas/app/inventory/Location.json
Normal file
24
schemas/app/inventory/Location.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "Location",
|
||||
"label": "Location",
|
||||
"isSingle": false,
|
||||
"isChild": false,
|
||||
"naming": "manual",
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "name",
|
||||
"label": "Location Name",
|
||||
"fieldtype": "Data",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"fieldname": "address",
|
||||
"label": "Address",
|
||||
"fieldtype": "Link",
|
||||
"target": "Address",
|
||||
"placeholder": "Click to create",
|
||||
"inline": true
|
||||
}
|
||||
],
|
||||
"quickEditFields": ["item", "address"]
|
||||
}
|
34
schemas/app/inventory/PurchaseReceipt.json
Normal file
34
schemas/app/inventory/PurchaseReceipt.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "PurchaseReceipt",
|
||||
"label": "Purchase Receipt",
|
||||
"extends": "StockTransfer",
|
||||
"naming": "numberSeries",
|
||||
"showTitle": true,
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "items",
|
||||
"label": "Items",
|
||||
"fieldtype": "Table",
|
||||
"target": "PurchaseReceiptItem",
|
||||
"required": true,
|
||||
"edit": true
|
||||
},
|
||||
{
|
||||
"fieldname": "numberSeries",
|
||||
"label": "Number Series",
|
||||
"fieldtype": "Link",
|
||||
"target": "NumberSeries",
|
||||
"create": true,
|
||||
"required": true,
|
||||
"default": "PREC-"
|
||||
},
|
||||
{
|
||||
"fieldname": "backReference",
|
||||
"label": "Back Reference",
|
||||
"fieldtype": "Link",
|
||||
"target": "PurchaseInvoice",
|
||||
"readOnly": true
|
||||
}
|
||||
],
|
||||
"keywordFields": ["name", "party"]
|
||||
}
|
15
schemas/app/inventory/PurchaseReceiptItem.json
Normal file
15
schemas/app/inventory/PurchaseReceiptItem.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "PurchaseReceiptItem",
|
||||
"label": "Purchase Receipt Item",
|
||||
"extends": "StockTransferItem",
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "location",
|
||||
"label": "To Loc.",
|
||||
"fieldtype": "Link",
|
||||
"target": "Location",
|
||||
"required": true,
|
||||
"create": true
|
||||
}
|
||||
]
|
||||
}
|
34
schemas/app/inventory/Shipment.json
Normal file
34
schemas/app/inventory/Shipment.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "Shipment",
|
||||
"label": "Shipment",
|
||||
"extends": "StockTransfer",
|
||||
"naming": "numberSeries",
|
||||
"showTitle": true,
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "items",
|
||||
"label": "Items",
|
||||
"fieldtype": "Table",
|
||||
"target": "ShipmentItem",
|
||||
"required": true,
|
||||
"edit": true
|
||||
},
|
||||
{
|
||||
"fieldname": "numberSeries",
|
||||
"label": "Number Series",
|
||||
"fieldtype": "Link",
|
||||
"target": "NumberSeries",
|
||||
"create": true,
|
||||
"required": true,
|
||||
"default": "SHPM-"
|
||||
},
|
||||
{
|
||||
"fieldname": "backReference",
|
||||
"label": "Back Reference",
|
||||
"fieldtype": "Link",
|
||||
"target": "SalesInvoice",
|
||||
"readOnly": true
|
||||
}
|
||||
],
|
||||
"keywordFields": ["name", "party"]
|
||||
}
|
15
schemas/app/inventory/ShipmentItem.json
Normal file
15
schemas/app/inventory/ShipmentItem.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "ShipmentItem",
|
||||
"label": "Shipment Item",
|
||||
"extends": "StockTransferItem",
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "location",
|
||||
"label": "From Loc.",
|
||||
"fieldtype": "Link",
|
||||
"target": "Location",
|
||||
"required": true,
|
||||
"create": true
|
||||
}
|
||||
]
|
||||
}
|
54
schemas/app/inventory/StockLedgerEntry.json
Normal file
54
schemas/app/inventory/StockLedgerEntry.json
Normal file
@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "StockLedgerEntry",
|
||||
"label": "Stock Ledger Entry",
|
||||
"isSingle": false,
|
||||
"isChild": false,
|
||||
"naming": "autoincrement",
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "date",
|
||||
"label": "Date",
|
||||
"fieldtype": "Datetime",
|
||||
"readOnly": true
|
||||
},
|
||||
{
|
||||
"fieldname": "item",
|
||||
"label": "Item",
|
||||
"fieldtype": "Link",
|
||||
"target": "Item",
|
||||
"readOnly": true
|
||||
},
|
||||
{
|
||||
"fieldname": "rate",
|
||||
"label": "Rate",
|
||||
"fieldtype": "Currency",
|
||||
"readOnly": true
|
||||
},
|
||||
{
|
||||
"fieldname": "quantity",
|
||||
"label": "Quantity",
|
||||
"fieldtype": "Float",
|
||||
"readOnly": true
|
||||
},
|
||||
{
|
||||
"fieldname": "location",
|
||||
"label": "Location",
|
||||
"fieldtype": "Link",
|
||||
"target": "Location",
|
||||
"readOnly": true
|
||||
},
|
||||
{
|
||||
"fieldname": "referenceName",
|
||||
"label": "Ref. Name",
|
||||
"fieldtype": "DynamicLink",
|
||||
"references": "referenceType",
|
||||
"readOnly": true
|
||||
},
|
||||
{
|
||||
"fieldname": "referenceType",
|
||||
"label": "Ref. Type",
|
||||
"fieldtype": "Data",
|
||||
"readOnly": true
|
||||
}
|
||||
]
|
||||
}
|
73
schemas/app/inventory/StockMovement.json
Normal file
73
schemas/app/inventory/StockMovement.json
Normal file
@ -0,0 +1,73 @@
|
||||
{
|
||||
"name": "StockMovement",
|
||||
"label": "Stock Movement",
|
||||
"naming": "numberSeries",
|
||||
"isSingle": false,
|
||||
"isChild": false,
|
||||
"isSubmittable": true,
|
||||
"fields": [
|
||||
{
|
||||
"label": "Stock Movement No.",
|
||||
"fieldname": "name",
|
||||
"fieldtype": "Data",
|
||||
"required": true,
|
||||
"readOnly": true
|
||||
},
|
||||
{
|
||||
"fieldname": "date",
|
||||
"label": "Date",
|
||||
"fieldtype": "Datetime",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"fieldname": "movementType",
|
||||
"label": "Movement Type",
|
||||
"fieldtype": "Select",
|
||||
"options": [
|
||||
{
|
||||
"value": "MaterialIssue",
|
||||
"label": "Material Issue"
|
||||
},
|
||||
{
|
||||
"value": "MaterialReceipt",
|
||||
"label": "Material Receipt"
|
||||
},
|
||||
{
|
||||
"value": "MaterialTransfer",
|
||||
"label": "Material Transfer"
|
||||
}
|
||||
],
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"fieldname": "numberSeries",
|
||||
"label": "Number Series",
|
||||
"fieldtype": "Link",
|
||||
"target": "NumberSeries",
|
||||
"create": true,
|
||||
"required": true,
|
||||
"default": "SMOV-"
|
||||
},
|
||||
{
|
||||
"fieldname": "amount",
|
||||
"label": "Total Amount",
|
||||
"fieldtype": "Currency",
|
||||
"readOnly": true
|
||||
},
|
||||
{
|
||||
"fieldname": "items",
|
||||
"label": "Items",
|
||||
"fieldtype": "Table",
|
||||
"target": "StockMovementItem",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"quickEditFields": [
|
||||
"numberSeries",
|
||||
"date",
|
||||
"movementType",
|
||||
"amount",
|
||||
"items"
|
||||
],
|
||||
"keywordFields": ["name", "movementType"]
|
||||
}
|
59
schemas/app/inventory/StockMovementItem.json
Normal file
59
schemas/app/inventory/StockMovementItem.json
Normal file
@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "StockMovementItem",
|
||||
"label": "Stock Movement Item",
|
||||
"naming": "random",
|
||||
"isChild": true,
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "item",
|
||||
"label": "Item",
|
||||
"fieldtype": "Link",
|
||||
"target": "Item",
|
||||
"create": true,
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"fieldname": "fromLocation",
|
||||
"label": "From",
|
||||
"fieldtype": "Link",
|
||||
"target": "Location",
|
||||
"create": true
|
||||
},
|
||||
{
|
||||
"fieldname": "toLocation",
|
||||
"label": "To",
|
||||
"fieldtype": "Link",
|
||||
"target": "Location",
|
||||
"create": true
|
||||
},
|
||||
{
|
||||
"fieldname": "quantity",
|
||||
"label": "Quantity",
|
||||
"fieldtype": "Float",
|
||||
"required": true,
|
||||
"default": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "rate",
|
||||
"label": "Rate",
|
||||
"fieldtype": "Currency",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"fieldname": "amount",
|
||||
"label": "Amount",
|
||||
"fieldtype": "Currency",
|
||||
"readOnly": true
|
||||
}
|
||||
],
|
||||
"tableFields": ["item", "fromLocation", "toLocation", "quantity", "rate"],
|
||||
"keywordFields": ["item"],
|
||||
"quickEditFields": [
|
||||
"item",
|
||||
"fromLocation",
|
||||
"toLocation",
|
||||
"quantity",
|
||||
"rate",
|
||||
"amount"
|
||||
]
|
||||
}
|
50
schemas/app/inventory/StockTransfer.json
Normal file
50
schemas/app/inventory/StockTransfer.json
Normal file
@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "StockTransfer",
|
||||
"label": "StockTransfer",
|
||||
"isAbstract": true,
|
||||
"isSingle": false,
|
||||
"isChild": false,
|
||||
"isSubmittable": true,
|
||||
"fields": [
|
||||
{
|
||||
"label": "Transfer No",
|
||||
"fieldname": "name",
|
||||
"fieldtype": "Data",
|
||||
"required": true,
|
||||
"readOnly": true
|
||||
},
|
||||
{
|
||||
"fieldname": "date",
|
||||
"label": "Date",
|
||||
"fieldtype": "Datetime",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"fieldname": "party",
|
||||
"label": "Party",
|
||||
"fieldtype": "Link",
|
||||
"target": "Party",
|
||||
"create": true,
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"fieldname": "terms",
|
||||
"label": "Notes",
|
||||
"placeholder": "Add transfer terms",
|
||||
"fieldtype": "Text"
|
||||
},
|
||||
{
|
||||
"fieldname": "attachment",
|
||||
"placeholder": "Add attachment",
|
||||
"label": "Attachment",
|
||||
"fieldtype": "Attachment"
|
||||
},
|
||||
{
|
||||
"fieldname": "grandTotal",
|
||||
"label": "Grand Total",
|
||||
"fieldtype": "Currency",
|
||||
"readOnly": true
|
||||
}
|
||||
],
|
||||
"keywordFields": ["name", "party"]
|
||||
}
|
71
schemas/app/inventory/StockTransferItem.json
Normal file
71
schemas/app/inventory/StockTransferItem.json
Normal file
@ -0,0 +1,71 @@
|
||||
{
|
||||
"name": "StockTransferItem",
|
||||
"label": "Stock Transfer Item",
|
||||
"isAbstract": true,
|
||||
"isChild": true,
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "item",
|
||||
"label": "Item",
|
||||
"fieldtype": "Link",
|
||||
"target": "Item",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"fieldname": "location",
|
||||
"fieldtype": "Link",
|
||||
"target": "Location",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"fieldname": "quantity",
|
||||
"label": "Quantity",
|
||||
"fieldtype": "Float",
|
||||
"required": true,
|
||||
"default": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "rate",
|
||||
"label": "Rate",
|
||||
"fieldtype": "Currency",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"fieldname": "amount",
|
||||
"label": "Amount",
|
||||
"fieldtype": "Currency",
|
||||
"readOnly": true
|
||||
},
|
||||
{
|
||||
"fieldname": "unit",
|
||||
"label": "Unit Type",
|
||||
"fieldtype": "Link",
|
||||
"target": "UOM",
|
||||
"default": "Unit",
|
||||
"placeholder": "Unit Type"
|
||||
},
|
||||
{
|
||||
"fieldname": "description",
|
||||
"label": "Description",
|
||||
"placeholder": "Item Description",
|
||||
"fieldtype": "Text"
|
||||
},
|
||||
{
|
||||
"fieldname": "hsnCode",
|
||||
"label": "HSN/SAC",
|
||||
"fieldtype": "Int",
|
||||
"placeholder": "HSN/SAC Code"
|
||||
}
|
||||
],
|
||||
"tableFields": ["item", "location", "quantity", "rate", "amount"],
|
||||
"quickEditFields": [
|
||||
"item",
|
||||
"unit",
|
||||
"description",
|
||||
"hsnCode",
|
||||
"location",
|
||||
"quantity",
|
||||
"rate",
|
||||
"amount"
|
||||
]
|
||||
}
|
@ -127,7 +127,7 @@ function addNameField(schemaMap: SchemaMap) {
|
||||
continue;
|
||||
}
|
||||
|
||||
schema.fields.push(NAME_FIELD as Field);
|
||||
schema.fields.unshift(NAME_FIELD as Field);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -7,6 +7,16 @@ import CompanySettings from './app/CompanySettings.json';
|
||||
import Currency from './app/Currency.json';
|
||||
import Defaults from './app/Defaults.json';
|
||||
import GetStarted from './app/GetStarted.json';
|
||||
import Location from './app/inventory/Location.json';
|
||||
import PurchaseReceipt from './app/inventory/PurchaseReceipt.json';
|
||||
import PurchaseReceiptItem from './app/inventory/PurchaseReceiptItem.json';
|
||||
import Shipment from './app/inventory/Shipment.json';
|
||||
import ShipmentItem from './app/inventory/ShipmentItem.json';
|
||||
import StockLedgerEntry from './app/inventory/StockLedgerEntry.json';
|
||||
import StockMovement from './app/inventory/StockMovement.json';
|
||||
import StockMovementItem from './app/inventory/StockMovementItem.json';
|
||||
import StockTransfer from './app/inventory/StockTransfer.json';
|
||||
import StockTransferItem from './app/inventory/StockTransferItem.json';
|
||||
import Invoice from './app/Invoice.json';
|
||||
import InvoiceItem from './app/InvoiceItem.json';
|
||||
import Item from './app/Item.json';
|
||||
@ -35,6 +45,7 @@ import child from './meta/child.json';
|
||||
import submittable from './meta/submittable.json';
|
||||
import tree from './meta/tree.json';
|
||||
import { Schema, SchemaStub } from './types';
|
||||
import InventorySettings from './app/inventory/InventorySettings.json';
|
||||
|
||||
export const coreSchemas: Schema[] = [
|
||||
PatchRun as Schema,
|
||||
@ -88,4 +99,17 @@ export const appSchemas: Schema[] | SchemaStub[] = [
|
||||
Tax as Schema,
|
||||
TaxDetail as Schema,
|
||||
TaxSummary as Schema,
|
||||
|
||||
InventorySettings as Schema,
|
||||
Location as Schema,
|
||||
StockLedgerEntry as Schema,
|
||||
StockMovement as Schema,
|
||||
StockMovementItem as Schema,
|
||||
|
||||
StockTransfer as Schema,
|
||||
StockTransferItem as Schema,
|
||||
Shipment as Schema,
|
||||
ShipmentItem as Schema,
|
||||
PurchaseReceipt as Schema,
|
||||
PurchaseReceiptItem as Schema,
|
||||
];
|
||||
|
8
scripts/test.sh
Executable file
8
scripts/test.sh
Executable file
@ -0,0 +1,8 @@
|
||||
TEST_PATH=$@
|
||||
|
||||
if [ $# -eq 0 ]
|
||||
then
|
||||
TEST_PATH=./**/tests/**/*.spec.ts
|
||||
fi
|
||||
|
||||
./scripts/runner.sh ./node_modules/.bin/tape $TEST_PATH | ./node_modules/.bin/tap-spec
|
@ -41,12 +41,13 @@ import { ModelNameEnum } from 'models/types';
|
||||
import { computed } from 'vue';
|
||||
import WindowsTitleBar from './components/WindowsTitleBar.vue';
|
||||
import { handleErrorWithDialog } from './errorHandling';
|
||||
import { fyo, initializeInstance } from './initFyo';
|
||||
import { fyo } from './initFyo';
|
||||
import DatabaseSelector from './pages/DatabaseSelector.vue';
|
||||
import Desk from './pages/Desk.vue';
|
||||
import SetupWizard from './pages/SetupWizard/SetupWizard.vue';
|
||||
import setupInstance from './setup/setupInstance';
|
||||
import './styles/index.css';
|
||||
import { initializeInstance } from './utils/initialization';
|
||||
import { checkForUpdates } from './utils/ipcCalls';
|
||||
import { updateConfigFiles } from './utils/misc';
|
||||
import { Search } from './utils/search';
|
||||
|
@ -24,6 +24,7 @@ const components = {
|
||||
Select,
|
||||
Link,
|
||||
Date,
|
||||
Datetime: Date,
|
||||
Table,
|
||||
AutoComplete,
|
||||
DynamicLink,
|
||||
|
@ -61,17 +61,20 @@
|
||||
<div class="flex items-center pl-1">
|
||||
<feather-icon name="plus" class="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
<div class="flex justify-between px-2">
|
||||
{{ t`Add Row` }}
|
||||
</div>
|
||||
<div v-for="i in ratio.slice(3).length" :key="i"></div>
|
||||
<div
|
||||
class="text-right px-2"
|
||||
v-if="
|
||||
value && maxRowsBeforeOverflow && value.length > maxRowsBeforeOverflow
|
||||
"
|
||||
>
|
||||
{{ t`${value.length} rows` }}
|
||||
<div class="flex justify-between px-2" :style="`grid-column: 2 / ${ratio.length + 1}`">
|
||||
<p>
|
||||
{{ t`Add Row` }}
|
||||
</p>
|
||||
<p
|
||||
class="text-right px-2"
|
||||
v-if="
|
||||
value &&
|
||||
maxRowsBeforeOverflow &&
|
||||
value.length > maxRowsBeforeOverflow
|
||||
"
|
||||
>
|
||||
{{ t`${value.length} rows` }}
|
||||
</p>
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
|
@ -16,6 +16,7 @@
|
||||
self-center
|
||||
w-form
|
||||
h-full
|
||||
overflow-auto
|
||||
mb-4
|
||||
bg-white
|
||||
"
|
||||
|
@ -17,7 +17,7 @@
|
||||
<script>
|
||||
import Base from '../base';
|
||||
export default {
|
||||
name: 'IconReports',
|
||||
name: 'IconGST',
|
||||
extends: Base,
|
||||
};
|
||||
</script>
|
||||
|
33
src/components/Icons/18/inventory.vue
Normal file
33
src/components/Icons/18/inventory.vue
Normal file
@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M7.4943 0.151836C7.88385 -0.0506121 8.33973 -0.0506121 8.72928 0.151836L17.0957 4.49986L14.7724 5.70728L5.78847 1.03835L7.4943 0.151836Z"
|
||||
:fill="lightColor"
|
||||
/>
|
||||
<path
|
||||
d="M9.88794 8.24572L0.904053 3.57679L4.11554 1.90778L13.0994 6.57671L9.88794 8.24572Z"
|
||||
:fill="lightColor"
|
||||
/>
|
||||
<path
|
||||
d="M10.6569 9.58498L17.9952 5.77127C17.9984 5.81193 18 5.85301 18 5.89441V12.453C18 12.9868 17.7304 13.4692 17.3093 13.7319L10.6569 17.8806L10.6569 9.58498Z"
|
||||
:fill="darkColor"
|
||||
/>
|
||||
<path
|
||||
d="M9.11923 9.58498L9.11923 18L0.78452 13.6684C0.313082 13.4234 0 12.9121 0 12.336V4.97134C0 4.92994 0.00161725 4.88887 0.00479439 4.84821L9.11923 9.58498Z"
|
||||
:fill="darkColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<script>
|
||||
import Base from '../base';
|
||||
export default {
|
||||
name: 'IconInventory',
|
||||
extends: Base,
|
||||
};
|
||||
</script>
|
@ -6,7 +6,7 @@ export default {
|
||||
props: ['active'],
|
||||
computed: {
|
||||
lightColor() {
|
||||
return this.active ? uicolors.blue['300'] : uicolors.gray['400'];
|
||||
return this.active ? uicolors.blue['300'] : uicolors.gray['500'];
|
||||
},
|
||||
darkColor() {
|
||||
return this.active ? uicolors.blue['500'] : uicolors.gray['700'];
|
||||
|
@ -167,7 +167,7 @@ export default {
|
||||
async mounted() {
|
||||
const { companyName } = await fyo.doc.getDoc('AccountingSettings');
|
||||
this.companyName = companyName;
|
||||
this.groups = getSidebarConfig();
|
||||
this.groups = await getSidebarConfig();
|
||||
|
||||
this.setActiveGroup();
|
||||
router.afterEach(() => {
|
||||
@ -208,11 +208,18 @@ export default {
|
||||
}
|
||||
},
|
||||
isItemActive(item) {
|
||||
let { path: currentRoute, params } = this.$route;
|
||||
let routeMatch = currentRoute === item.route;
|
||||
let schemaNameMatch =
|
||||
const { path: currentRoute, params } = this.$route;
|
||||
const routeMatch = currentRoute === item.route;
|
||||
|
||||
const schemaNameMatch =
|
||||
item.schemaName && params.schemaName === item.schemaName;
|
||||
return routeMatch || schemaNameMatch;
|
||||
|
||||
const isMatch = routeMatch || schemaNameMatch;
|
||||
if (params.name && item.schemaName && !isMatch) {
|
||||
return currentRoute.includes(`${item.schemaName}/${params.name}`);
|
||||
}
|
||||
|
||||
return isMatch;
|
||||
},
|
||||
isGroupActive(group) {
|
||||
return this.activeGroup && group.label === this.activeGroup.label;
|
||||
|
@ -1,22 +1,25 @@
|
||||
<template>
|
||||
<Badge class="text-sm flex-center px-3 ml-2" :color="color" v-if="status">{{
|
||||
statusLabel
|
||||
}}</Badge>
|
||||
<Badge
|
||||
class="flex-center"
|
||||
:color="color"
|
||||
v-if="status"
|
||||
:class="defaultSize ? 'text-sm px-3' : ''"
|
||||
>{{ statusLabel }}</Badge
|
||||
>
|
||||
</template>
|
||||
<script>
|
||||
import { getStatusMap, statusColor } from 'models/helpers';
|
||||
import { getStatusText, statusColor } from 'models/helpers';
|
||||
import Badge from './Badge.vue';
|
||||
|
||||
export default {
|
||||
name: 'StatusBadge',
|
||||
props: ['status'],
|
||||
props: { status: String, defaultSize: { type: Boolean, default: true } },
|
||||
computed: {
|
||||
color() {
|
||||
return statusColor[this.status];
|
||||
},
|
||||
statusLabel() {
|
||||
const statusMap = getStatusMap();
|
||||
return statusMap[this.status] ?? this.status;
|
||||
return getStatusText(this.status) || this.status;
|
||||
},
|
||||
},
|
||||
components: { Badge },
|
||||
|
@ -125,6 +125,11 @@ export default {
|
||||
focusFirstInput: Boolean,
|
||||
readOnly: { type: [null, Boolean], default: null },
|
||||
},
|
||||
watch: {
|
||||
doc() {
|
||||
this.setFormFields();
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
inlineEditDoc: null,
|
||||
@ -268,7 +273,7 @@ export default {
|
||||
try {
|
||||
await this.inlineEditDoc.sync();
|
||||
} catch (error) {
|
||||
return await handleErrorWithDialog(error, this.inlineEditDoc)
|
||||
return await handleErrorWithDialog(error, this.inlineEditDoc);
|
||||
}
|
||||
|
||||
await this.onChangeCommon(df, this.inlineEditDoc.name);
|
||||
|
90
src/components/Widgets/LinkedEntryWidget.vue
Normal file
90
src/components/Widgets/LinkedEntryWidget.vue
Normal file
@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="w-quick-edit border-l bg-white flex flex-col">
|
||||
<!-- Linked Entry Title -->
|
||||
<div class="flex items-center justify-between px-4 h-row-largest border-b">
|
||||
<Button :icon="true" @click="$emit('close-widget')">
|
||||
<feather-icon name="x" class="w-4 h-4" />
|
||||
</Button>
|
||||
<p class="font-semibold text-xl text-gray-600">
|
||||
{{ linked.title }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Linked Entry Items -->
|
||||
<div
|
||||
v-for="entry in linked.entries"
|
||||
:key="entry.name"
|
||||
class="p-4 border-b flex flex-col hover:bg-gray-50 cursor-pointer"
|
||||
@click="openEntry(entry.name)"
|
||||
>
|
||||
<!-- Name And Status -->
|
||||
<div class="mb-2 flex justify-between items-center">
|
||||
<p class="font-semibold text-gray-900">
|
||||
{{ entry.name }}
|
||||
</p>
|
||||
<StatusBadge
|
||||
:status="getStatus(entry)"
|
||||
:default-size="false"
|
||||
class="px-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Date and Amount -->
|
||||
<div class="text-sm flex justify-between items-center">
|
||||
<p>
|
||||
{{ fyo.format(entry.date as Date, 'Date') }}
|
||||
</p>
|
||||
<p>{{ fyo.format(entry.amount as Money, 'Currency') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Quantity and Location -->
|
||||
<div
|
||||
v-if="['Shipment', 'PurchaseReceipt'].includes(linked.schemaName)"
|
||||
class="text-sm flex justify-between items-center mt-1"
|
||||
>
|
||||
<p>
|
||||
{{ entry.location }}
|
||||
</p>
|
||||
<p>
|
||||
{{ t`Qty. ${fyo.format(entry.quantity as number, 'Float')}` }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Money } from 'pesa';
|
||||
import { getEntryRoute } from 'src/router';
|
||||
import { getStatus, routeTo } from 'src/utils/ui';
|
||||
import { defineComponent, PropType } from 'vue';
|
||||
import Button from '../Button.vue';
|
||||
import StatusBadge from '../StatusBadge.vue';
|
||||
|
||||
interface Linked {
|
||||
schemaName: string;
|
||||
title: string;
|
||||
entries: {
|
||||
name: string;
|
||||
cancelled: boolean;
|
||||
submitted: boolean;
|
||||
[key: string]: unknown;
|
||||
}[];
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
emits: ['close-widget'],
|
||||
props: {
|
||||
linked: { type: Object as PropType<Linked>, required: true },
|
||||
},
|
||||
methods: {
|
||||
getStatus,
|
||||
async openEntry(name: string) {
|
||||
console.log('op', name);
|
||||
const route = getEntryRoute(this.linked.schemaName, name);
|
||||
await routeTo(route);
|
||||
},
|
||||
},
|
||||
components: { Button, StatusBadge },
|
||||
});
|
||||
</script>
|
132
src/initFyo.ts
132
src/initFyo.ts
@ -1,130 +1,8 @@
|
||||
import { Fyo } from 'fyo';
|
||||
import { ConfigFile, ConfigKeys } from 'fyo/core/types';
|
||||
import { getRegionalModels, models } from 'models';
|
||||
import { ModelNameEnum } from 'models/types';
|
||||
import { getRandomString, getValueMapFromList } from 'utils';
|
||||
|
||||
export const fyo = new Fyo({ isTest: false, isElectron: true });
|
||||
/**
|
||||
* Global fyo: this is meant to be used only by the app. For
|
||||
* testing purposes a separate instance of fyo should be initialized.
|
||||
*/
|
||||
|
||||
async function closeDbIfConnected() {
|
||||
if (!fyo.db.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fyo.db.purgeCache();
|
||||
}
|
||||
|
||||
export async function initializeInstance(
|
||||
dbPath: string,
|
||||
isNew: boolean,
|
||||
countryCode: string,
|
||||
fyo: Fyo
|
||||
) {
|
||||
if (isNew) {
|
||||
await closeDbIfConnected();
|
||||
countryCode = await fyo.db.createNewDatabase(dbPath, countryCode);
|
||||
} else if (!fyo.db.isConnected) {
|
||||
countryCode = await fyo.db.connectToDatabase(dbPath);
|
||||
}
|
||||
|
||||
const regionalModels = await getRegionalModels(countryCode);
|
||||
await fyo.initializeAndRegister(models, regionalModels);
|
||||
|
||||
await setSingles(fyo);
|
||||
await setCreds(fyo);
|
||||
await setVersion(fyo);
|
||||
setDeviceId(fyo);
|
||||
await setInstanceId(fyo);
|
||||
await setOpenCount(fyo);
|
||||
await setCurrencySymbols(fyo);
|
||||
}
|
||||
|
||||
async function setSingles(fyo: Fyo) {
|
||||
await fyo.doc.getDoc(ModelNameEnum.AccountingSettings);
|
||||
await fyo.doc.getDoc(ModelNameEnum.GetStarted);
|
||||
await fyo.doc.getDoc(ModelNameEnum.Defaults);
|
||||
await fyo.doc.getDoc(ModelNameEnum.Misc);
|
||||
}
|
||||
|
||||
async function setCreds(fyo: Fyo) {
|
||||
const email = (await fyo.getValue(
|
||||
ModelNameEnum.AccountingSettings,
|
||||
'email'
|
||||
)) as string | undefined;
|
||||
const user = fyo.auth.user;
|
||||
fyo.auth.user = email ?? user;
|
||||
}
|
||||
|
||||
async function setVersion(fyo: Fyo) {
|
||||
const version = (await fyo.getValue(
|
||||
ModelNameEnum.SystemSettings,
|
||||
'version'
|
||||
)) as string | undefined;
|
||||
|
||||
const { appVersion } = fyo.store;
|
||||
if (version !== appVersion) {
|
||||
const systemSettings = await fyo.doc.getDoc(ModelNameEnum.SystemSettings);
|
||||
await systemSettings?.setAndSync('version', appVersion);
|
||||
}
|
||||
}
|
||||
|
||||
function setDeviceId(fyo: Fyo) {
|
||||
let deviceId = fyo.config.get(ConfigKeys.DeviceId) as string | undefined;
|
||||
if (deviceId === undefined) {
|
||||
deviceId = getRandomString();
|
||||
fyo.config.set(ConfigKeys.DeviceId, deviceId);
|
||||
}
|
||||
|
||||
fyo.store.deviceId = deviceId;
|
||||
}
|
||||
|
||||
async function setInstanceId(fyo: Fyo) {
|
||||
const systemSettings = await fyo.doc.getDoc(ModelNameEnum.SystemSettings);
|
||||
if (!systemSettings.instanceId) {
|
||||
await systemSettings.setAndSync('instanceId', getRandomString());
|
||||
}
|
||||
|
||||
fyo.store.instanceId = (await fyo.getValue(
|
||||
ModelNameEnum.SystemSettings,
|
||||
'instanceId'
|
||||
)) as string;
|
||||
}
|
||||
|
||||
export async function setCurrencySymbols(fyo: Fyo) {
|
||||
const currencies = (await fyo.db.getAll(ModelNameEnum.Currency, {
|
||||
fields: ['name', 'symbol'],
|
||||
})) as { name: string; symbol: string }[];
|
||||
|
||||
fyo.currencySymbols = getValueMapFromList(
|
||||
currencies,
|
||||
'name',
|
||||
'symbol'
|
||||
) as Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
async function setOpenCount(fyo: Fyo) {
|
||||
const misc = await fyo.doc.getDoc(ModelNameEnum.Misc);
|
||||
let openCount = misc.openCount as number | null;
|
||||
|
||||
if (typeof openCount !== 'number') {
|
||||
openCount = getOpenCountFromFiles(fyo);
|
||||
}
|
||||
|
||||
if (typeof openCount !== 'number') {
|
||||
openCount = 0;
|
||||
}
|
||||
|
||||
openCount += 1;
|
||||
await misc.setAndSync('openCount', openCount);
|
||||
}
|
||||
|
||||
function getOpenCountFromFiles(fyo: Fyo) {
|
||||
const configFile = fyo.config.get(ConfigKeys.Files, []) as ConfigFile[];
|
||||
for (const file of configFile) {
|
||||
if (file.id === fyo.singles.SystemSettings?.instanceId) {
|
||||
return file.openCount ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
export const fyo = new Fyo({ isTest: false, isElectron: true });
|
@ -301,7 +301,9 @@ export default {
|
||||
this.creatingDemo = false;
|
||||
},
|
||||
async setFiles() {
|
||||
this.files = await ipcRenderer.invoke(IPC_ACTIONS.GET_DB_LIST);
|
||||
this.files = (await ipcRenderer.invoke(IPC_ACTIONS.GET_DB_LIST))?.sort(
|
||||
(a, b) => Date.parse(b.modified) - Date.parse(a.modified)
|
||||
);
|
||||
},
|
||||
async newDatabase() {
|
||||
if (this.creatingDemo) {
|
||||
|
299
src/pages/GeneralForm.vue
Normal file
299
src/pages/GeneralForm.vue
Normal file
@ -0,0 +1,299 @@
|
||||
<template>
|
||||
<FormContainer>
|
||||
<!-- Page Header (Title, Buttons, etc) -->
|
||||
<template #header v-if="doc">
|
||||
<StatusBadge :status="status" />
|
||||
<DropdownWithActions
|
||||
v-for="group of groupedActions"
|
||||
:key="group.label"
|
||||
:type="group.type"
|
||||
:actions="group.actions"
|
||||
>
|
||||
<p v-if="group.group">
|
||||
{{ group.group }}
|
||||
</p>
|
||||
<feather-icon v-else name="more-horizontal" class="w-4 h-4" />
|
||||
</DropdownWithActions>
|
||||
<Button
|
||||
v-if="doc?.notInserted || doc?.dirty"
|
||||
type="primary"
|
||||
@click="sync"
|
||||
>
|
||||
{{ t`Save` }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!doc?.dirty && !doc?.notInserted && !doc?.submitted"
|
||||
type="primary"
|
||||
@click="submit"
|
||||
>{{ t`Submit` }}</Button
|
||||
>
|
||||
</template>
|
||||
|
||||
<!-- Form Header -->
|
||||
<template #body v-if="doc">
|
||||
<FormHeader
|
||||
:form-title="doc.notInserted ? t`New Entry` : doc.name"
|
||||
:form-sub-title="doc.schema?.label ?? ''"
|
||||
/>
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<!-- Form Data Entry -->
|
||||
<div class="m-4 grid grid-cols-3 gap-4">
|
||||
<FormControl
|
||||
input-class="font-semibold"
|
||||
:border="true"
|
||||
:df="getField('party')"
|
||||
:value="doc.party"
|
||||
@change="(value) => doc.set('party', value, true)"
|
||||
@new-doc="(party) => doc.set('party', party.name, true)"
|
||||
:read-only="doc?.submitted"
|
||||
/>
|
||||
<FormControl
|
||||
input-class="text-right"
|
||||
:border="true"
|
||||
:df="getField('date')"
|
||||
:value="doc.date"
|
||||
@change="(value) => doc.set('date', value)"
|
||||
:read-only="doc?.submitted"
|
||||
/>
|
||||
<FormControl
|
||||
input-class="text-right"
|
||||
:border="true"
|
||||
:df="getField('numberSeries')"
|
||||
:value="doc.numberSeries"
|
||||
@change="(value) => doc.set('numberSeries', value)"
|
||||
:read-only="!doc.notInserted || doc?.submitted"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="doc.backReference"
|
||||
:border="true"
|
||||
:df="getField('backReference')"
|
||||
:value="doc.backReference"
|
||||
:read-only="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="doc.attachment || !(doc.isSubmitted || doc.isCancelled)"
|
||||
:border="true"
|
||||
:df="getField('attachment')"
|
||||
:value="doc.attachment"
|
||||
@change="(value) => doc.set('attachment', value)"
|
||||
:read-only="doc?.submitted"
|
||||
/>
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<!-- Items Table -->
|
||||
<Table
|
||||
class="text-base"
|
||||
:df="getField('items')"
|
||||
:value="doc.items"
|
||||
:showHeader="true"
|
||||
:max-rows-before-overflow="4"
|
||||
@change="(value) => doc.set('items', value)"
|
||||
@editrow="toggleQuickEditDoc"
|
||||
:read-only="doc?.submitted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Form Footer -->
|
||||
<div v-if="doc.items?.length ?? 0" class="mt-auto">
|
||||
<hr />
|
||||
<div class="flex justify-between text-base m-4 gap-12">
|
||||
<div class="w-1/2 flex flex-col justify-between">
|
||||
<!-- Form Terms-->
|
||||
<FormControl
|
||||
:border="true"
|
||||
v-if="!doc?.submitted || doc.terms"
|
||||
:df="getField('terms')"
|
||||
:value="doc.terms"
|
||||
class="mt-auto"
|
||||
@change="(value) => doc.set('terms', value)"
|
||||
:read-only="doc?.submitted"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-1/2" v-if="doc.grandTotal">
|
||||
<!-- Grand Total -->
|
||||
<div
|
||||
class="
|
||||
flex
|
||||
justify-between
|
||||
text-green-600
|
||||
font-semibold
|
||||
text-base
|
||||
"
|
||||
>
|
||||
<div>{{ t`Grand Total` }}</div>
|
||||
<div>{{ formattedValue('grandTotal') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #quickedit v-if="quickEditDoc">
|
||||
<QuickEditForm
|
||||
class="w-quick-edit"
|
||||
:name="quickEditDoc.name"
|
||||
:show-name="false"
|
||||
:show-save="false"
|
||||
:source-doc="quickEditDoc"
|
||||
:source-fields="quickEditFields"
|
||||
:schema-name="quickEditDoc.schemaName"
|
||||
:white="true"
|
||||
:route-back="false"
|
||||
:load-on-close="false"
|
||||
@close="toggleQuickEditDoc(null)"
|
||||
/>
|
||||
</template>
|
||||
</FormContainer>
|
||||
</template>
|
||||
<script>
|
||||
import { computed } from '@vue/reactivity';
|
||||
import { t } from 'fyo';
|
||||
import { getDocStatus } from 'models/helpers';
|
||||
import Button from 'src/components/Button.vue';
|
||||
import FormControl from 'src/components/Controls/FormControl.vue';
|
||||
import Table from 'src/components/Controls/Table.vue';
|
||||
import DropdownWithActions from 'src/components/DropdownWithActions.vue';
|
||||
import FormContainer from 'src/components/FormContainer.vue';
|
||||
import FormHeader from 'src/components/FormHeader.vue';
|
||||
import StatusBadge from 'src/components/StatusBadge.vue';
|
||||
import { fyo } from 'src/initFyo';
|
||||
import { docsPathMap } from 'src/utils/misc';
|
||||
import {
|
||||
docsPath,
|
||||
getGroupedActionsForDoc,
|
||||
routeTo,
|
||||
showMessageDialog,
|
||||
} from 'src/utils/ui';
|
||||
import { nextTick } from 'vue';
|
||||
import { handleErrorWithDialog } from '../errorHandling';
|
||||
import QuickEditForm from './QuickEditForm.vue';
|
||||
|
||||
export default {
|
||||
name: 'InvoiceForm',
|
||||
props: { schemaName: String, name: String },
|
||||
components: {
|
||||
StatusBadge,
|
||||
Button,
|
||||
FormControl,
|
||||
DropdownWithActions,
|
||||
Table,
|
||||
FormContainer,
|
||||
QuickEditForm,
|
||||
FormHeader,
|
||||
},
|
||||
provide() {
|
||||
return {
|
||||
schemaName: this.schemaName,
|
||||
name: this.name,
|
||||
doc: computed(() => this.doc),
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
chstatus: false,
|
||||
doc: null,
|
||||
quickEditDoc: null,
|
||||
quickEditFields: [],
|
||||
printSettings: null,
|
||||
};
|
||||
},
|
||||
updated() {
|
||||
this.chstatus = !this.chstatus;
|
||||
},
|
||||
computed: {
|
||||
status() {
|
||||
this.chstatus;
|
||||
return getDocStatus(this.doc);
|
||||
},
|
||||
groupedActions() {
|
||||
return getGroupedActionsForDoc(this.doc);
|
||||
},
|
||||
},
|
||||
activated() {
|
||||
docsPath.value = docsPathMap[this.schemaName];
|
||||
},
|
||||
deactivated() {
|
||||
docsPath.value = '';
|
||||
},
|
||||
async mounted() {
|
||||
try {
|
||||
this.doc = await fyo.doc.getDoc(this.schemaName, this.name);
|
||||
} catch (error) {
|
||||
if (error instanceof fyo.errors.NotFoundError) {
|
||||
routeTo(`/list/${this.schemaName}`);
|
||||
return;
|
||||
}
|
||||
await this.handleError(error);
|
||||
}
|
||||
|
||||
let query = this.$route.query;
|
||||
if (query.values && query.schemaName === this.schemaName) {
|
||||
this.doc.set(this.$router.currentRoute.value.query.values);
|
||||
}
|
||||
|
||||
if (fyo.store.isDevelopment) {
|
||||
window.frm = this;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
routeTo,
|
||||
async toggleQuickEditDoc(doc, fields = []) {
|
||||
if (this.quickEditDoc && doc) {
|
||||
this.quickEditDoc = null;
|
||||
this.quickEditFields = [];
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
this.quickEditDoc = doc;
|
||||
this.quickEditFields = fields;
|
||||
},
|
||||
getField(fieldname) {
|
||||
return fyo.getField(this.schemaName, fieldname);
|
||||
},
|
||||
async sync() {
|
||||
try {
|
||||
await this.doc.sync();
|
||||
} catch (err) {
|
||||
await this.handleError(err);
|
||||
}
|
||||
},
|
||||
async submit() {
|
||||
const message = t`Submit ${this.doc.name}`;
|
||||
const ref = this;
|
||||
await showMessageDialog({
|
||||
message,
|
||||
buttons: [
|
||||
{
|
||||
label: t`Yes`,
|
||||
async action() {
|
||||
try {
|
||||
await ref.doc.submit();
|
||||
} catch (err) {
|
||||
await ref.handleError(err);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t`No`,
|
||||
action() {},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
async handleError(e) {
|
||||
await handleErrorWithDialog(e, this.doc);
|
||||
},
|
||||
formattedValue(fieldname, doc) {
|
||||
if (!doc) {
|
||||
doc = this.doc;
|
||||
}
|
||||
|
||||
const df = this.getField(fieldname);
|
||||
return fyo.format(doc[fieldname], df, doc);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
@ -27,7 +27,17 @@
|
||||
>
|
||||
<feather-icon name="settings" class="w-4 h-4" />
|
||||
</Button>
|
||||
<DropdownWithActions :actions="actions()" />
|
||||
<DropdownWithActions
|
||||
v-for="group of groupedActions"
|
||||
:key="group.label"
|
||||
:type="group.type"
|
||||
:actions="group.actions"
|
||||
>
|
||||
<p v-if="group.group">
|
||||
{{ group.group }}
|
||||
</p>
|
||||
<feather-icon v-else name="more-horizontal" class="w-4 h-4" />
|
||||
</DropdownWithActions>
|
||||
<Button
|
||||
v-if="doc?.notInserted || doc?.dirty"
|
||||
type="primary"
|
||||
@ -120,10 +130,15 @@
|
||||
<hr />
|
||||
<div class="flex justify-between text-base m-4 gap-12">
|
||||
<div class="w-1/2 flex flex-col justify-between">
|
||||
<!-- Discount Note -->
|
||||
<!-- Info Note -->
|
||||
<p v-if="discountNote?.length" class="text-gray-600 text-sm">
|
||||
{{ discountNote }}
|
||||
</p>
|
||||
|
||||
<p v-if="stockTransferText?.length" class="text-gray-600 text-sm">
|
||||
{{ stockTransferText }}
|
||||
</p>
|
||||
|
||||
<!-- Form Terms-->
|
||||
<FormControl
|
||||
:border="true"
|
||||
@ -257,8 +272,9 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #quickedit v-if="quickEditDoc">
|
||||
<template #quickedit v-if="quickEditDoc || linked">
|
||||
<QuickEditForm
|
||||
v-if="quickEditDoc && !linked"
|
||||
class="w-quick-edit"
|
||||
:name="quickEditDoc.name"
|
||||
:show-name="false"
|
||||
@ -271,6 +287,12 @@
|
||||
:load-on-close="false"
|
||||
@close="toggleQuickEditDoc(null)"
|
||||
/>
|
||||
|
||||
<LinkedEntryWidget
|
||||
v-if="linked && !quickEditDoc"
|
||||
:linked="linked"
|
||||
@close-widget="linked = null"
|
||||
/>
|
||||
</template>
|
||||
</FormContainer>
|
||||
</template>
|
||||
@ -286,13 +308,14 @@ import DropdownWithActions from 'src/components/DropdownWithActions.vue';
|
||||
import FormContainer from 'src/components/FormContainer.vue';
|
||||
import FormHeader from 'src/components/FormHeader.vue';
|
||||
import StatusBadge from 'src/components/StatusBadge.vue';
|
||||
import LinkedEntryWidget from 'src/components/Widgets/LinkedEntryWidget.vue';
|
||||
import { fyo } from 'src/initFyo';
|
||||
import { docsPathMap } from 'src/utils/misc';
|
||||
import {
|
||||
docsPath,
|
||||
getActionsForDocument,
|
||||
routeTo,
|
||||
showMessageDialog
|
||||
docsPath,
|
||||
getGroupedActionsForDoc,
|
||||
routeTo,
|
||||
showMessageDialog,
|
||||
} from 'src/utils/ui';
|
||||
import { nextTick } from 'vue';
|
||||
import { handleErrorWithDialog } from '../errorHandling';
|
||||
@ -311,6 +334,7 @@ export default {
|
||||
QuickEditForm,
|
||||
ExchangeRate,
|
||||
FormHeader,
|
||||
LinkedEntryWidget,
|
||||
},
|
||||
provide() {
|
||||
return {
|
||||
@ -328,12 +352,63 @@ export default {
|
||||
color: null,
|
||||
printSettings: null,
|
||||
companyName: null,
|
||||
linked: null,
|
||||
};
|
||||
},
|
||||
updated() {
|
||||
this.chstatus = !this.chstatus;
|
||||
},
|
||||
computed: {
|
||||
stockTransferText() {
|
||||
if (!this.fyo.singles.AccountingSettings.enableInventory) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!this.doc.submitted) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const totalQuantity = this.doc.getTotalQuantity();
|
||||
const stockNotTransferred = this.doc.stockNotTransferred;
|
||||
|
||||
if (stockNotTransferred === 0) {
|
||||
return this.t`Stock has been transferred`;
|
||||
}
|
||||
|
||||
const stn = this.fyo.format(stockNotTransferred, 'Float');
|
||||
const tq = this.fyo.format(totalQuantity, 'Float');
|
||||
|
||||
return this.t`Stock qty. ${stn} out of ${tq} left to transfer`;
|
||||
},
|
||||
groupedActions() {
|
||||
const actions = getGroupedActionsForDoc(this.doc);
|
||||
const group = this.t`View`;
|
||||
const viewgroup = actions.find((f) => f.group === group);
|
||||
|
||||
if (viewgroup && this.doc?.hasLinkedPayments) {
|
||||
viewgroup.actions.push({
|
||||
label: this.t`Payments`,
|
||||
group,
|
||||
condition: (doc) => doc.hasLinkedPayments,
|
||||
action: async () => this.setlinked(ModelNameEnum.Payment),
|
||||
});
|
||||
}
|
||||
|
||||
if (viewgroup && this.doc?.hasLinkedTransfers) {
|
||||
const label = this.doc.isSales
|
||||
? this.t`Shipments`
|
||||
: this.t`Purchase Receipts`;
|
||||
|
||||
viewgroup.actions.push({
|
||||
label,
|
||||
group,
|
||||
condition: (doc) => doc.hasLinkedTransfers,
|
||||
action: async () => this.setlinked(this.doc.stockTransferSchemaName),
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
},
|
||||
address() {
|
||||
return this.printSettings && this.printSettings.getLink('address');
|
||||
},
|
||||
@ -406,6 +481,26 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
routeTo,
|
||||
async setlinked(schemaName) {
|
||||
let entries = [];
|
||||
let title = '';
|
||||
|
||||
if (schemaName === ModelNameEnum.Payment) {
|
||||
title = this.t`Payments`;
|
||||
entries = await this.doc.getLinkedPayments();
|
||||
} else {
|
||||
title = this.doc.isSales
|
||||
? this.t`Shipments`
|
||||
: this.t`Purchase Receipts`;
|
||||
entries = await this.doc.getLinkedStockTransfers();
|
||||
}
|
||||
|
||||
if (this.quickEditDoc) {
|
||||
this.toggleQuickEditDoc(null);
|
||||
}
|
||||
|
||||
this.linked = { entries, schemaName, title };
|
||||
},
|
||||
toggleInvoiceSettings() {
|
||||
if (!this.schemaName) {
|
||||
return;
|
||||
@ -425,11 +520,17 @@ export default {
|
||||
}
|
||||
|
||||
this.quickEditDoc = doc;
|
||||
if (
|
||||
doc?.schemaName?.includes('InvoiceItem') &&
|
||||
doc?.stockNotTransferred
|
||||
) {
|
||||
fields = [...doc.schema.quickEditFields, 'stockNotTransferred'].map(
|
||||
(f) => fyo.getField(doc.schemaName, f)
|
||||
);
|
||||
}
|
||||
|
||||
this.quickEditFields = fields;
|
||||
},
|
||||
actions() {
|
||||
return getActionsForDocument(this.doc);
|
||||
},
|
||||
getField(fieldname) {
|
||||
return fyo.getField(this.schemaName, fieldname);
|
||||
},
|
||||
|
@ -3,7 +3,17 @@
|
||||
<!-- Page Header (Title, Buttons, etc) -->
|
||||
<template #header v-if="doc">
|
||||
<StatusBadge :status="status" />
|
||||
<DropdownWithActions :actions="actions" />
|
||||
<DropdownWithActions
|
||||
v-for="group of groupedActions"
|
||||
:key="group.label"
|
||||
:type="group.type"
|
||||
:actions="group.actions"
|
||||
>
|
||||
<p v-if="group.group">
|
||||
{{ group.group }}
|
||||
</p>
|
||||
<feather-icon v-else name="more-horizontal" class="w-4 h-4" />
|
||||
</DropdownWithActions>
|
||||
<Button
|
||||
v-if="doc?.notInserted || doc?.dirty"
|
||||
type="primary"
|
||||
@ -139,10 +149,10 @@ import StatusBadge from 'src/components/StatusBadge.vue';
|
||||
import { fyo } from 'src/initFyo';
|
||||
import { docsPathMap } from 'src/utils/misc';
|
||||
import {
|
||||
docsPath,
|
||||
getActionsForDocument,
|
||||
routeTo,
|
||||
showMessageDialog
|
||||
docsPath,
|
||||
getGroupedActionsForDoc,
|
||||
routeTo,
|
||||
showMessageDialog,
|
||||
} from 'src/utils/ui';
|
||||
import { handleErrorWithDialog } from '../errorHandling';
|
||||
|
||||
@ -156,8 +166,8 @@ export default {
|
||||
FormControl,
|
||||
Table,
|
||||
FormContainer,
|
||||
FormHeader
|
||||
},
|
||||
FormHeader,
|
||||
},
|
||||
provide() {
|
||||
return {
|
||||
schemaName: this.schemaName,
|
||||
@ -211,8 +221,8 @@ export default {
|
||||
}
|
||||
return fyo.format(value, 'Currency');
|
||||
},
|
||||
actions() {
|
||||
return getActionsForDocument(this.doc);
|
||||
groupedActions() {
|
||||
return getGroupedActionsForDoc(this.doc);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
@ -46,7 +46,7 @@ import PageHeader from 'src/components/PageHeader.vue';
|
||||
import { fyo } from 'src/initFyo';
|
||||
import { docsPathMap } from 'src/utils/misc';
|
||||
import { docsPath, routeTo } from 'src/utils/ui';
|
||||
import List from './List';
|
||||
import List from './List.vue';
|
||||
|
||||
export default {
|
||||
name: 'ListView',
|
||||
|
@ -110,7 +110,7 @@ import StatusBadge from 'src/components/StatusBadge.vue';
|
||||
import TwoColumnForm from 'src/components/TwoColumnForm.vue';
|
||||
import { fyo } from 'src/initFyo';
|
||||
import { getQuickEditWidget } from 'src/utils/quickEditWidgets';
|
||||
import { getActionsForDocument, openQuickEdit } from 'src/utils/ui';
|
||||
import { getActionsForDoc, openQuickEdit } from 'src/utils/ui';
|
||||
|
||||
export default {
|
||||
name: 'QuickEditForm',
|
||||
@ -198,7 +198,7 @@ export default {
|
||||
return fieldnames.map((f) => fyo.getField(this.schemaName, f));
|
||||
},
|
||||
actions() {
|
||||
return getActionsForDocument(this.doc);
|
||||
return getActionsForDoc(this.doc);
|
||||
},
|
||||
quickEditWidget() {
|
||||
if (this.doc?.notInserted ?? true) {
|
||||
|
@ -14,7 +14,10 @@
|
||||
</PageHeader>
|
||||
|
||||
<!-- Filters -->
|
||||
<div v-if="report" class="grid grid-cols-5 gap-4 p-4 border-b">
|
||||
<div
|
||||
v-if="report && report.filters.length"
|
||||
class="grid grid-cols-5 gap-4 p-4 border-b"
|
||||
>
|
||||
<FormControl
|
||||
v-for="field in report.filters"
|
||||
:border="true"
|
||||
@ -100,7 +103,7 @@ export default defineComponent({
|
||||
acc[ac.group] ??= {
|
||||
group: ac.group,
|
||||
label: ac.label ?? '',
|
||||
type: ac.type,
|
||||
e: ac.type,
|
||||
actions: [],
|
||||
};
|
||||
|
||||
|
@ -29,7 +29,11 @@
|
||||
|
||||
<!-- Component -->
|
||||
<div class="flex-1 overflow-y-auto custom-scroll">
|
||||
<component :is="activeTabComponent" @change="handleChange" />
|
||||
<component
|
||||
:is="tabs[activeTab].component"
|
||||
:schema-name="tabs[activeTab].schemaName"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</FormContainer>
|
||||
@ -47,11 +51,10 @@ import { docsPathMap } from 'src/utils/misc';
|
||||
import { docsPath, showToast } from 'src/utils/ui';
|
||||
import { IPC_MESSAGES } from 'utils/messages';
|
||||
import { h, markRaw } from 'vue';
|
||||
import TabDefaults from './TabDefaults.vue';
|
||||
import TabBase from './TabBase.vue';
|
||||
import TabGeneral from './TabGeneral.vue';
|
||||
import TabInvoice from './TabInvoice.vue';
|
||||
import TabSystem from './TabSystem.vue';
|
||||
|
||||
export default {
|
||||
name: 'Settings',
|
||||
components: {
|
||||
@ -62,6 +65,8 @@ export default {
|
||||
FormContainer,
|
||||
},
|
||||
data() {
|
||||
const hasInventory = !!fyo.singles.AccountingSettings?.enableInventory;
|
||||
|
||||
return {
|
||||
activeTab: 0,
|
||||
updated: false,
|
||||
@ -70,21 +75,35 @@ export default {
|
||||
{
|
||||
key: 'Invoice',
|
||||
label: t`Invoice`,
|
||||
schemaName: 'PrintSettings',
|
||||
component: markRaw(TabInvoice),
|
||||
},
|
||||
{
|
||||
key: 'General',
|
||||
label: t`General`,
|
||||
schemaName: 'AccountingSettings',
|
||||
component: markRaw(TabGeneral),
|
||||
},
|
||||
{
|
||||
key: 'Defaults',
|
||||
label: t`Defaults`,
|
||||
component: markRaw(TabDefaults),
|
||||
schemaName: 'Defaults',
|
||||
component: markRaw(TabBase),
|
||||
},
|
||||
...(hasInventory
|
||||
? [
|
||||
{
|
||||
key: 'Inventory',
|
||||
label: t`Inventory`,
|
||||
schemaName: 'InventorySettings',
|
||||
component: markRaw(TabBase),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'System',
|
||||
label: t`System`,
|
||||
schemaName: 'SystemSettings',
|
||||
component: markRaw(TabSystem),
|
||||
},
|
||||
],
|
||||
@ -105,7 +124,8 @@ export default {
|
||||
fieldnames.includes('displayPrecision') ||
|
||||
fieldnames.includes('hideGetStarted') ||
|
||||
fieldnames.includes('displayPrecision') ||
|
||||
fieldnames.includes('enableDiscounting')
|
||||
fieldnames.includes('enableDiscounting') ||
|
||||
fieldnames.includes('enableInventory')
|
||||
) {
|
||||
this.showReloadToast();
|
||||
}
|
||||
@ -154,10 +174,5 @@ export default {
|
||||
};
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
activeTabComponent() {
|
||||
return this.tabs[this.activeTab].component;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -12,15 +12,39 @@
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Doc } from 'fyo/model/doc';
|
||||
import { Field } from 'schemas/types';
|
||||
import TwoColumnForm from 'src/components/TwoColumnForm.vue';
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: 'TabGeneral',
|
||||
emits: ['change'],
|
||||
props: { schemaName: String },
|
||||
components: {
|
||||
TwoColumnForm,
|
||||
},
|
||||
async mounted() {
|
||||
await this.setDoc();
|
||||
},
|
||||
watch: {
|
||||
async schemaName() {
|
||||
await this.setDoc();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async setDoc() {
|
||||
if (this.doc && this.schemaName === this.doc.schemaName) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.schemaName) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.doc = await this.fyo.doc.getDoc(this.schemaName, this.schemaName, {
|
||||
skipDocumentCache: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
doc: undefined,
|
||||
@ -28,8 +52,8 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
fields() {
|
||||
return [] as Field[];
|
||||
return this.doc?.schema.fields;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
@ -1,20 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { fyo } from 'src/initFyo';
|
||||
import { defineComponent } from 'vue';
|
||||
import TabBase from './TabBase.vue';
|
||||
|
||||
export default defineComponent({
|
||||
extends: TabBase,
|
||||
name: 'TabDefaults',
|
||||
async mounted() {
|
||||
this.doc = await fyo.doc.getDoc('Defaults', 'Defaults', {
|
||||
skipDocumentCache: true,
|
||||
});
|
||||
},
|
||||
computed: {
|
||||
fields() {
|
||||
return this.doc?.schema.fields;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user