2
0
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:
Alan 2022-12-01 00:57:11 -08:00 committed by GitHub
commit 78e13db385
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
113 changed files with 5739 additions and 482 deletions

View File

@ -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]];
}
}

View File

@ -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> {

View File

@ -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}`,
});
}
}

View File

@ -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();
});

View File

@ -46,6 +46,7 @@ export const databaseMethodSet: Set<DatabaseMethod> = new Set([
'rename',
'update',
'delete',
'deleteAll',
'close',
'exists',
]);

View File

@ -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();

View 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 };

View File

@ -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[];

View File

@ -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) {

View File

@ -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);
}
}

View File

@ -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>,
};
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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;
};

View File

@ -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');
});

View File

@ -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') {

View File

@ -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,
{

View File

@ -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) {

View File

@ -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',
};

View File

@ -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());
}
}

View File

@ -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 = {

View File

@ -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,
};
}

View File

@ -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>`,

View File

@ -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'],
};
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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>`,
};
},
};
}

View File

@ -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(

View 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'],
}),
};
}

View File

@ -0,0 +1,5 @@
import { Doc } from 'fyo/model/doc';
export class Location extends Doc {
item?: string;
}

View 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',
],
};
}
}

View File

@ -0,0 +1,3 @@
import { StockTransferItem } from './StockTransferItem';
export class PurchaseReceiptItem extends StockTransferItem {}

View 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',
],
};
}
}

View File

@ -0,0 +1,3 @@
import { StockTransferItem } from './StockTransferItem';
export class ShipmentItem extends StockTransferItem {}

View 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;
}

View 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 = [];
}
}

View 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)];
}
}

View 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' }),
};
}

View 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',
}),
};
}

View 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 };
},
};
}

View 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[];
}

View File

@ -0,0 +1 @@

View 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;
}
}

View 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[];
}

View 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);

View 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();
});

View 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
View 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 {}

View File

@ -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;

View File

@ -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",

View File

@ -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() {
return [
{
fieldtype: 'Select',
options: [
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: refTypeOptions,
label: t`Ref Type`,
fieldname: 'referenceType',
placeholder: t`Ref Type`,

View File

@ -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[] = [];

View File

@ -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>;

View 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);
}
}

View 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);
}
}

View 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;
}

View 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';

View File

@ -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",

View File

@ -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"
}
]
}

View File

@ -131,6 +131,12 @@
"placeholder": "Add attachment",
"label": "Attachment",
"fieldtype": "Attachment"
},
{
"fieldname": "stockNotTransferred",
"label": "Stock Not Transferred",
"fieldtype": "Float",
"readOnly": true
}
],
"keywordFields": ["name", "party"]

View File

@ -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"],

View File

@ -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"]
}

View File

@ -46,6 +46,18 @@
{
"value": "JournalEntry",
"label": "Journal Entry"
},
{
"value": "StockMovement",
"label": "Stock Movement"
},
{
"value": "Shipment",
"label": "Shipment"
},
{
"value": "PurchaseReceipt",
"label": "Purchase Receipt"
}
],
"default": "-",

View File

@ -34,7 +34,6 @@
"label": "Customer"
}
],
"readOnly": true,
"required": true
},
{

View 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"
}
]
}

View 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"]
}

View 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"]
}

View 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
}
]
}

View 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"]
}

View 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
}
]
}

View 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
}
]
}

View 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"]
}

View 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"
]
}

View 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"]
}

View 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"
]
}

View File

@ -127,7 +127,7 @@ function addNameField(schemaMap: SchemaMap) {
continue;
}
schema.fields.push(NAME_FIELD as Field);
schema.fields.unshift(NAME_FIELD as Field);
}
}

View File

@ -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
View 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

View File

@ -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';

View File

@ -24,6 +24,7 @@ const components = {
Select,
Link,
Date,
Datetime: Date,
Table,
AutoComplete,
DynamicLink,

View File

@ -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">
<div class="flex justify-between px-2" :style="`grid-column: 2 / ${ratio.length + 1}`">
<p>
{{ t`Add Row` }}
</div>
<div v-for="i in ratio.slice(3).length" :key="i"></div>
<div
</p>
<p
class="text-right px-2"
v-if="
value && maxRowsBeforeOverflow && value.length > maxRowsBeforeOverflow
value &&
maxRowsBeforeOverflow &&
value.length > maxRowsBeforeOverflow
"
>
{{ t`${value.length} rows` }}
</p>
</div>
</Row>
</div>

View File

@ -16,6 +16,7 @@
self-center
w-form
h-full
overflow-auto
mb-4
bg-white
"

View File

@ -17,7 +17,7 @@
<script>
import Base from '../base';
export default {
name: 'IconReports',
name: 'IconGST',
extends: Base,
};
</script>

View 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>

View File

@ -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'];

View File

@ -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;

View File

@ -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 },

View File

@ -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);

View 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>

View File

@ -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';
/**
* Global fyo: this is meant to be used only by the app. For
* testing purposes a separate instance of fyo should be initialized.
*/
export const fyo = new Fyo({ isTest: false, isElectron: true });
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;
}

View File

@ -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
View 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>

View File

@ -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);
},

View File

@ -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: {

View File

@ -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',

View File

@ -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) {

View File

@ -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: [],
};

View File

@ -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>

View File

@ -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>

View File

@ -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