2
0
mirror of https://github.com/frappe/books.git synced 2024-11-09 15:20:56 +00:00

Merge pull request #709 from akshayitzme/feat-pos-page

feat: point of sale
This commit is contained in:
Akshay 2023-12-04 15:05:50 +05:30 committed by GitHub
commit 82a2c5e5b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 3155 additions and 4 deletions

View File

@ -8,8 +8,10 @@ import {
import { ModelNameEnum } from '../../models/types';
import DatabaseCore from './core';
import { BespokeFunction } from './types';
import { DateTime } from 'luxon';
import { DocItem, ReturnDocItem } from 'models/inventory/types';
import { safeParseFloat } from 'utils/index';
import { Money } from 'pesa';
export class BespokeQueries {
[key: string]: BespokeFunction;
@ -390,4 +392,59 @@ export class BespokeQueries {
}
return returnBalanceItems;
}
static async getPOSTransactedAmount(
db: DatabaseCore,
fromDate: Date,
toDate: Date,
lastShiftClosingDate?: Date
): Promise<Record<string, Money> | undefined> {
const sinvNamesQuery = db.knex!(ModelNameEnum.SalesInvoice)
.select('name')
.where('isPOS', true)
.andWhereBetween('date', [
DateTime.fromJSDate(fromDate).toSQLDate(),
DateTime.fromJSDate(toDate).toSQLDate(),
]);
if (lastShiftClosingDate) {
sinvNamesQuery.andWhere(
'created',
'>',
DateTime.fromJSDate(lastShiftClosingDate).toUTC().toString()
);
}
const sinvNames = (await sinvNamesQuery).map(
(row: { name: string }) => row.name
);
if (!sinvNames.length) {
return;
}
const paymentEntryNames: string[] = (
await db.knex!(ModelNameEnum.PaymentFor)
.select('parent')
.whereIn('referenceName', sinvNames)
).map((doc: { parent: string }) => doc.parent);
const groupedAmounts = (await db.knex!(ModelNameEnum.Payment)
.select('paymentMethod')
.whereIn('name', paymentEntryNames)
.groupBy('paymentMethod')
.sum({ amount: 'amount' })) as { paymentMethod: string; amount: Money }[];
const transactedAmounts = {} as { [paymentMethod: string]: Money };
if (!groupedAmounts) {
return;
}
for (const row of groupedAmounts) {
transactedAmounts[row.paymentMethod] = row.amount;
}
return transactedAmounts;
}
}

View File

@ -27,6 +27,7 @@ import {
RawValueMap,
} from './types';
import { ReturnDocItem } from 'models/inventory/types';
import { Money } from 'pesa';
type FieldMap = Record<string, Record<string, Field>>;
@ -342,6 +343,19 @@ export class DatabaseHandler extends DatabaseBase {
)) as Promise<Record<string, ReturnDocItem> | undefined>;
}
async getPOSTransactedAmount(
fromDate: Date,
toDate: Date,
lastShiftClosingDate?: Date
): Promise<Record<string, Money> | undefined> {
return (await this.#demux.callBespoke(
'getPOSTransactedAmount',
fromDate,
toDate,
lastShiftClosingDate
)) as Promise<Record<string, Money> | undefined>;
}
/**
* Internal methods
*/

View File

@ -10,6 +10,8 @@ import type { Defaults } from 'models/baseModels/Defaults/Defaults';
import type { PrintSettings } from 'models/baseModels/PrintSettings/PrintSettings';
import type { InventorySettings } from 'models/inventory/InventorySettings';
import type { Misc } from 'models/baseModels/Misc';
import type { POSSettings } from 'models/inventory/Point of Sale/POSSettings';
import type { POSShift } from 'models/inventory/Point of Sale/POSShift';
/**
* The functions below are used for dynamic evaluation
@ -54,6 +56,8 @@ export interface SinglesMap {
SystemSettings?: SystemSettings;
AccountingSettings?: AccountingSettings;
InventorySettings?: InventorySettings;
POSSettings?: POSSettings;
POSShift?: POSShift;
PrintSettings?: PrintSettings;
Defaults?: Defaults;
Misc?: Misc;

View File

@ -1,6 +1,8 @@
import { DefaultCashDenominations } from 'models/inventory/Point of Sale/DefaultCashDenominations';
import { Doc } from 'fyo/model/doc';
import { FiltersMap, HiddenMap } from 'fyo/model/types';
import { ModelNameEnum } from 'models/types';
import { PartyRoleEnum } from '../Party/types';
export class Defaults extends Doc {
// Auto Payments
@ -35,6 +37,10 @@ export class Defaults extends Doc {
purchaseReceiptPrintTemplate?: string;
stockMovementPrintTemplate?: string;
// Point of Sale
posCashDenominations?: DefaultCashDenominations[];
posCustomer?: string;
static commonFilters = {
// Auto Payments
salesPaymentAccount: () => ({ isGroup: false, accountType: 'Cash' }),
@ -73,6 +79,7 @@ export class Defaults extends Doc {
type: ModelNameEnum.PurchaseReceipt,
}),
stockMovementPrintTemplate: () => ({ type: ModelNameEnum.StockMovement }),
posCustomer: () => ({ role: PartyRoleEnum.Customer }),
};
static filters: FiltersMap = this.commonFilters;
@ -82,6 +89,10 @@ export class Defaults extends Doc {
return () => !this.fyo.singles.AccountingSettings?.enableInventory;
}
getPointOfSaleHidden() {
return () => !this.fyo.singles.InventorySettings?.enablePointOfSale;
}
hidden: HiddenMap = {
stockMovementNumberSeries: this.getInventoryHidden(),
shipmentNumberSeries: this.getInventoryHidden(),
@ -91,6 +102,8 @@ export class Defaults extends Doc {
shipmentPrintTemplate: this.getInventoryHidden(),
purchaseReceiptPrintTemplate: this.getInventoryHidden(),
stockMovementPrintTemplate: this.getInventoryHidden(),
posCashDenominations: this.getPointOfSaleHidden(),
posCustomer: this.getPointOfSaleHidden(),
};
}

View File

@ -33,6 +33,12 @@ import { ShipmentItem } from './inventory/ShipmentItem';
import { StockLedgerEntry } from './inventory/StockLedgerEntry';
import { StockMovement } from './inventory/StockMovement';
import { StockMovementItem } from './inventory/StockMovementItem';
import { ClosingAmounts } from './inventory/Point of Sale/ClosingAmounts';
import { ClosingCash } from './inventory/Point of Sale/ClosingCash';
import { OpeningAmounts } from './inventory/Point of Sale/OpeningAmounts';
import { OpeningCash } from './inventory/Point of Sale/OpeningCash';
import { POSSettings } from './inventory/Point of Sale/POSSettings';
import { POSShift } from './inventory/Point of Sale/POSShift';
export const models = {
Account,
@ -70,6 +76,13 @@ export const models = {
ShipmentItem,
PurchaseReceipt,
PurchaseReceiptItem,
// POS Models
ClosingAmounts,
ClosingCash,
OpeningAmounts,
OpeningCash,
POSSettings,
POSShift,
} as ModelMap;
export async function getRegionalModels(

View File

@ -12,6 +12,7 @@ export class InventorySettings extends Doc {
enableSerialNumber?: boolean;
enableUomConversions?: boolean;
enableStockReturns?: boolean;
enablePointOfSale?: boolean;
static filters: FiltersMap = {
stockInHand: () => ({
@ -44,5 +45,8 @@ export class InventorySettings extends Doc {
enableStockReturns: () => {
return !!this.enableStockReturns;
},
enablePointOfSale: () => {
return !!this.fyo.singles.POSShift?.isShiftOpen;
},
};
}

View File

@ -0,0 +1,6 @@
import { Doc } from 'fyo/model/doc';
import { Money } from 'pesa';
export abstract class CashDenominations extends Doc {
denomination?: Money;
}

View File

@ -0,0 +1,27 @@
import { Doc } from 'fyo/model/doc';
import { FormulaMap } from 'fyo/model/types';
import { Money } from 'pesa';
export class ClosingAmounts extends Doc {
closingAmount?: Money;
differenceAmount?: Money;
expectedAmount?: Money;
openingAmount?: Money;
paymentMethod?: string;
formulas: FormulaMap = {
differenceAmount: {
formula: () => {
if (!this.closingAmount) {
return this.fyo.pesa(0);
}
if (!this.expectedAmount) {
return this.fyo.pesa(0);
}
return this.closingAmount.sub(this.expectedAmount);
},
},
};
}

View File

@ -0,0 +1,5 @@
import { CashDenominations } from './CashDenominations';
export class ClosingCash extends CashDenominations {
count?: number;
}

View File

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

View File

@ -0,0 +1,11 @@
import { Doc } from 'fyo/model/doc';
import { Money } from 'pesa';
export class OpeningAmounts extends Doc {
amount?: Money;
paymentMethod?: 'Cash' | 'Transfer';
get openingCashAmount() {
return this.parentdoc?.openingCashAmount as Money;
}
}

View File

@ -0,0 +1,5 @@
import { CashDenominations } from './CashDenominations';
export class OpeningCash extends CashDenominations {
count?: number;
}

View File

@ -0,0 +1,19 @@
import { Doc } from 'fyo/model/doc';
import { FiltersMap } from 'fyo/model/types';
import {
AccountRootTypeEnum,
AccountTypeEnum,
} from 'models/baseModels/Account/types';
export class POSSettings extends Doc {
inventory?: string;
cashAccount?: string;
writeOffAccount?: string;
static filters: FiltersMap = {
cashAccount: () => ({
rootType: AccountRootTypeEnum.Asset,
accountType: AccountTypeEnum.Cash,
}),
};
}

View File

@ -0,0 +1,61 @@
import { ClosingAmounts } from './ClosingAmounts';
import { ClosingCash } from './ClosingCash';
import { Doc } from 'fyo/model/doc';
import { OpeningAmounts } from './OpeningAmounts';
import { OpeningCash } from './OpeningCash';
export class POSShift extends Doc {
closingAmounts?: ClosingAmounts[];
closingCash?: ClosingCash[];
closingDate?: Date;
isShiftOpen?: boolean;
openingAmounts?: OpeningAmounts[];
openingCash?: OpeningCash[];
openingDate?: Date;
get openingCashAmount() {
if (!this.openingCash) {
return this.fyo.pesa(0);
}
let openingAmount = this.fyo.pesa(0);
this.openingCash.map((row: OpeningCash) => {
const denomination = row.denomination ?? this.fyo.pesa(0);
const count = row.count ?? 0;
const amount = denomination.mul(count);
openingAmount = openingAmount.add(amount);
});
return openingAmount;
}
get closingCashAmount() {
if (!this.closingCash) {
return this.fyo.pesa(0);
}
let closingAmount = this.fyo.pesa(0);
this.closingCash.map((row: ClosingCash) => {
const denomination = row.denomination ?? this.fyo.pesa(0);
const count = row.count ?? 0;
const amount = denomination.mul(count);
closingAmount = closingAmount.add(amount);
});
return closingAmount;
}
get openingTransferAmount() {
if (!this.openingAmounts) {
return this.fyo.pesa(0);
}
const transferAmountRow = this.openingAmounts.filter(
(row) => row.paymentMethod === 'Transfer'
)[0];
return transferAmountRow.amount ?? this.fyo.pesa(0);
}
}

View File

@ -0,0 +1,103 @@
import test from 'tape';
import { closeTestFyo, getTestFyo, setupTestFyo } from 'tests/helpers';
import { SalesInvoice } from 'models/baseModels/SalesInvoice/SalesInvoice';
import { Payment } from 'models/baseModels/Payment/Payment';
import { Money } from 'pesa';
import { ModelNameEnum } from 'models/types';
const fyo = getTestFyo();
setupTestFyo(fyo, __filename);
const customer = { name: 'Someone', role: 'Both' };
const itemMap = {
Pen: {
name: 'Pen',
rate: 700,
},
Ink: {
name: 'Ink',
rate: 50,
},
};
test('insert test docs', async (t) => {
await fyo.doc.getNewDoc(ModelNameEnum.Item, itemMap.Pen).sync();
await fyo.doc.getNewDoc(ModelNameEnum.Item, itemMap.Ink).sync();
await fyo.doc.getNewDoc(ModelNameEnum.Party, customer).sync();
});
let sinvDocOne: SalesInvoice | undefined;
test('check pos transacted amount', async (t) => {
const transactedAmountBeforeTxn = await fyo.db.getPOSTransactedAmount(
new Date('2023-01-01'),
new Date('2023-01-02')
);
t.equals(transactedAmountBeforeTxn, undefined);
sinvDocOne = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, {
isPOS: true,
date: new Date('2023-01-01'),
account: 'Debtors',
party: customer.name,
}) as SalesInvoice;
await sinvDocOne.append('items', {
item: itemMap.Pen.name,
rate: itemMap.Pen.rate,
quantity: 1,
});
await (await sinvDocOne.sync()).submit();
const paymentDocOne = sinvDocOne.getPayment() as Payment;
await paymentDocOne.sync();
const sinvDocTwo = fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, {
isPOS: true,
date: new Date('2023-01-01'),
account: 'Debtors',
party: customer.name,
}) as SalesInvoice;
await sinvDocTwo.append('items', {
item: itemMap.Pen.name,
rate: itemMap.Pen.rate,
quantity: 1,
});
await (await sinvDocTwo.sync()).submit();
const paymentDocTwo = sinvDocTwo.getPayment() as Payment;
await paymentDocTwo.setMultiple({
paymentMethod: 'Transfer',
clearanceDate: new Date('2023-01-01'),
referenceId: 'xxxxxxxx',
});
await paymentDocTwo.sync();
const transactedAmountAfterTxn: Record<string, Money> | undefined =
await fyo.db.getPOSTransactedAmount(
new Date('2023-01-01'),
new Date('2023-01-02')
);
t.true(transactedAmountAfterTxn);
t.equals(
transactedAmountAfterTxn?.Cash,
sinvDocOne.grandTotal?.float,
'transacted cash amount matches'
);
t.equals(
transactedAmountAfterTxn?.Transfer,
sinvDocTwo.grandTotal?.float,
'transacted transfer amount matches'
);
});
closeTestFyo(fyo, __filename);

View File

@ -45,7 +45,9 @@ export enum ModelNameEnum {
PurchaseReceiptItem = 'PurchaseReceiptItem',
Location = 'Location',
CustomForm = 'CustomForm',
CustomField = 'CustomField'
CustomField = 'CustomField',
POSSettings = 'POSSettings',
POSShift = 'POSShift'
}
export type ModelName = keyof typeof ModelNameEnum;

View File

@ -164,6 +164,21 @@
"fieldtype": "Link",
"target": "PrintTemplate",
"section": "Print Templates"
},
{
"fieldname": "posCustomer",
"label": "POS Customer",
"fieldtype": "Link",
"target": "Party",
"create": true,
"section": "Point of Sale"
},
{
"fieldname": "posCashDenominations",
"label": "Cash Denominations",
"fieldtype": "Table",
"target": "DefaultCashDenominations",
"section": "Point of Sale"
}
]
}

View File

@ -61,6 +61,12 @@
"target": "SalesInvoice",
"label": "Return Against",
"section": "References"
},
{
"fieldname": "isPOS",
"fieldtype": "Check",
"default": false,
"hidden": true
}
],
"keywordFields": ["name", "party"]

View File

@ -63,6 +63,13 @@
"fieldtype": "Check",
"default": false,
"section": "Features"
},
{
"fieldname": "enablePointOfSale",
"label": "Enable Point of Sale",
"fieldtype": "Check",
"default": false,
"section": "Features"
}
]
}

View File

@ -0,0 +1,14 @@
{
"name": "CashDenominations",
"label": "Cash Denominations",
"isAbstract": true,
"fields": [
{
"fieldname": "denomination",
"fieldtype": "Currency",
"label": "Denomination",
"placeholder": "Denomination",
"required": true
}
]
}

View File

@ -0,0 +1,42 @@
{
"name": "ClosingAmounts",
"label": "Closing Amount",
"isChild": true,
"extends": "POSShiftAmounts",
"fields": [
{
"fieldname": "openingAmount",
"fieldtype": "Currency",
"label": "Opening Amount",
"placeholder": "Opening Amount",
"readOnly": true
},
{
"fieldname": "closingAmount",
"fieldtype": "Currency",
"label": "Closing Amount",
"placeholder": "Closing Amount"
},
{
"fieldname": "expectedAmount",
"fieldtype": "Currency",
"label": "Expected Amount",
"placeholder": "Expected Amount",
"readOnly": true
},
{
"fieldname": "differenceAmount",
"fieldtype": "Currency",
"label": "Difference Amount",
"placeholder": "Difference Amount",
"readOnly": true
}
],
"tableFields": [
"paymentMethod",
"openingAmount",
"closingAmount",
"expectedAmount",
"differenceAmount"
]
}

View File

@ -0,0 +1,17 @@
{
"name": "ClosingCash",
"label": "Closing Cash In Denominations",
"isChild": true,
"extends": "CashDenominations",
"fields": [
{
"fieldname": "count",
"label": "Count",
"placeholder": "Count",
"fieldtype": "Int",
"default": 0,
"required": true
}
],
"tableFields": ["denomination", "count"]
}

View File

@ -0,0 +1,7 @@
{
"name": "DefaultCashDenominations",
"label": "Default Cash Denominations",
"isChild": true,
"extends": "CashDenominations",
"tableFields": ["denomination"]
}

View File

@ -0,0 +1,15 @@
{
"name": "OpeningAmounts",
"label": "Opening Amount",
"isChild": true,
"extends": "POSShiftAmounts",
"fields": [
{
"fieldname": "amount",
"label": "Amount",
"fieldtype": "Currency",
"section": "Defaults"
}
],
"tableFields": ["paymentMethod", "amount"]
}

View File

@ -0,0 +1,17 @@
{
"name": "OpeningCash",
"label": "Opening Cash In Denominations",
"isChild": true,
"extends": "CashDenominations",
"fields": [
{
"fieldname": "count",
"label": "Count",
"placeholder": "Count",
"fieldtype": "Int",
"default": 0,
"required": true
}
],
"tableFields": ["denomination", "count"]
}

View File

@ -0,0 +1,36 @@
{
"name": "POSSettings",
"label": "POS Settings",
"isSingle": true,
"isChild": false,
"fields": [
{
"fieldname": "inventory",
"label": "Inventory",
"fieldtype": "Link",
"target": "Location",
"create": true,
"default": "Stores",
"section": "Default"
},
{
"fieldname": "cashAccount",
"label": "Counter Cash Account",
"fieldtype": "Link",
"target": "Account",
"default": "Cash In Hand",
"required": true,
"create": true,
"section": "Default"
},
{
"fieldname": "writeOffAccount",
"label": "Write Off Account",
"fieldtype": "Link",
"target": "Account",
"create": true,
"default": "Write Off",
"section": "Default"
}
]
}

View File

@ -0,0 +1,43 @@
{
"name": "POSShift",
"isSingle": true,
"isChild": false,
"fields": [
{
"fieldname": "isShiftOpen",
"label": "Is POS Shift Open",
"fieldtype": "Check",
"default": false
},
{
"fieldname": "openingDate",
"label": "Opening Date",
"fieldtype": "Datetime"
},
{
"fieldname": "closingDate",
"label": "Closing Date",
"fieldtype": "Datetime"
},
{
"fieldname": "openingCash",
"fieldtype": "Table",
"target": "OpeningCash"
},
{
"fieldname": "closingCash",
"fieldtype": "Table",
"target": "ClosingCash"
},
{
"fieldname": "openingAmounts",
"fieldtype": "Table",
"target": "OpeningAmounts"
},
{
"fieldname": "closingAmounts",
"fieldtype": "Table",
"target": "ClosingAmounts"
}
]
}

View File

@ -0,0 +1,25 @@
{
"name": "POSShiftAmounts",
"label": "POS Shift Amount",
"isChild": true,
"isAbstract": true,
"fields": [
{
"fieldname": "paymentMethod",
"label": "Payment Method",
"placeholder": "Payment Method",
"fieldtype": "Select",
"options": [
{
"value": "Cash",
"label": "Cash"
},
{
"value": "Transfer",
"label": "Transfer"
}
],
"required": true
}
]
}

View File

@ -52,6 +52,15 @@ import base from './meta/base.json';
import child from './meta/child.json';
import submittable from './meta/submittable.json';
import tree from './meta/tree.json';
import CashDenominations from './app/inventory/Point of Sale/CashDenominations.json';
import ClosingAmounts from './app/inventory/Point of Sale/ClosingAmounts.json';
import ClosingCash from './app/inventory/Point of Sale/ClosingCash.json';
import DefaultCashDenominations from './app/inventory/Point of Sale/DefaultCashDenominations.json';
import OpeningAmounts from './app/inventory/Point of Sale/OpeningAmounts.json';
import OpeningCash from './app/inventory/Point of Sale/OpeningCash.json';
import POSSettings from './app/inventory/Point of Sale/POSSettings.json';
import POSShift from './app/inventory/Point of Sale/POSShift.json';
import POSShiftAmounts from './app/inventory/Point of Sale/POSShiftAmounts.json';
import { Schema, SchemaStub } from './types';
export const coreSchemas: Schema[] = [
@ -129,4 +138,14 @@ export const appSchemas: Schema[] | SchemaStub[] = [
CustomForm as Schema,
CustomField as Schema,
CashDenominations as Schema,
ClosingAmounts as Schema,
ClosingCash as Schema,
DefaultCashDenominations as Schema,
OpeningAmounts as Schema,
OpeningCash as Schema,
POSSettings as Schema,
POSShift as Schema,
POSShiftAmounts as Schema,
];

View File

@ -9,6 +9,7 @@ import Inventory from './inventory.vue';
import Invoice from './invoice.vue';
import Item from './item.vue';
import Mail from './mail.vue';
import POS from './pos.vue';
import OpeningAc from './opening-ac.vue';
import Percentage from './percentage.vue';
import Property from './property.vue';
@ -36,6 +37,7 @@ export default {
'invoice': Invoice,
'item': Item,
'mail': Mail,
'pos': POS,
'opening-ac': OpeningAc,
'percentage': Percentage,
'property': Property,

View File

@ -0,0 +1,15 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="none" d="M0 0h24v24H0z"></path>
<path
:fill="darkColor"
d="M21 13V20C21 20.5523 20.5523 21 20 21H4C3.44772 21 3 20.5523 3 20V13H2V11L3 6H21L22 11V13H21ZM5 13V19H19V13H5ZM6 14H14V17H6V14ZM3 3H21V5H3V3Z"
></path>
</svg>
</template>
<script>
import Base from '../base.vue';
export default {
extends: Base,
};
</script>

View File

@ -0,0 +1,116 @@
<template>
<div class="relative">
<input
:type="inputType"
:class="[inputClasses, size === 'large' ? 'text-lg' : 'text-sm']"
:value="round(value)"
:max="isNumeric(df) ? df.maxvalue : undefined"
:min="isNumeric(df) ? df.minvalue : undefined"
:readonly="isReadOnly"
:tabindex="isReadOnly ? '-1' : '0'"
@blur="onBlur"
class="
block
px-2.5
pb-2.5
pt-4
w-full
font-medium
text-gray-900
bg-gray-25
rounded-lg
border border-gray-200
appearance-none
focus:outline-none focus:ring-0
peer
"
/>
<label
for="floating_outlined"
:class="size === 'large' ? 'text-xl' : 'text-md'"
class="
absolute
font-medium
text-gray-500
duration-300
transform
-translate-y-4
scale-75
top-8
z-10
origin-[0]
bg-white2
px-2
peer-focus:px-2 peer-focus:text-blue-600
peer-placeholder-shown:scale-100
peer-placeholder-shown:-translate-y-1/2
peer-placeholder-shown:top-1/2
peer-focus:top-2 peer-focus:scale-75 peer-focus:-translate-y-4
left-1
"
>{{ currency ? fyo.currencySymbols[currency] : undefined }}</label
>
<label
for="floating_outlined"
:class="size === 'large' ? 'text-xl' : 'text-md'"
class="
absolute
font-medium
text-gray-500
duration-300
transform
-translate-y-4
scale-75
top-1
z-10
origin-[0]
bg-white2
px-2
peer-focus:px-2 peer-focus:text-blue-600
peer-placeholder-shown:scale-100
peer-placeholder-shown:-translate-y-1/2
peer-placeholder-shown:top-1/2
peer-focus:top-2 peer-focus:scale-75 peer-focus:-translate-y-4
left-1
"
>{{ df.label }}</label
>
</div>
</template>
<script lang="ts">
import FloatingLabelInputBase from './FloatingLabelInputBase.vue';
import { safeParsePesa } from 'utils/index';
import { isPesa } from 'fyo/utils';
import { fyo } from 'src/initFyo';
import { defineComponent } from 'vue';
import { Money } from 'pesa';
export default defineComponent({
name: 'FloatingLabelCurrencyInput',
extends: FloatingLabelInputBase,
computed: {
currency(): string | undefined {
if (this.value) {
return (this.value as Money).getCurrency();
}
},
},
methods: {
round(v: unknown) {
if (!isPesa(v)) {
v = this.parse(v);
}
if (isPesa(v)) {
return v.round();
}
return fyo.pesa(0).round();
},
parse(value: unknown): Money {
return safeParsePesa(value, this.fyo);
},
},
});
</script>

View File

@ -0,0 +1,14 @@
<script lang="ts">
import { defineComponent } from 'vue';
import FloatingLabelInputBase from './FloatingLabelInputBase.vue';
export default defineComponent({
name: 'FloatingLabelFloatInput',
extends: FloatingLabelInputBase,
computed: {
inputType() {
return 'number';
},
},
});
</script>

View File

@ -0,0 +1,63 @@
<template>
<div class="relative">
<input
:type="inputType"
:class="[inputClasses, size === 'large' ? 'text-lg' : 'text-sm']"
:value="value"
:max="isNumeric(df) ? df.maxvalue : undefined"
:min="isNumeric(df) ? df.minvalue : undefined"
:readonly="isReadOnly"
:tabindex="isReadOnly ? '-1' : '0'"
@blur="onBlur"
class="
block
px-2.5
pb-2.5
pt-4
w-full
font-medium
text-gray-900
bg-gray-25
rounded-lg
border border-gray-200
appearance-none
focus:outline-none focus:ring-0
peer
"
/>
<label
for="floating_outlined"
:class="size === 'large' ? 'text-xl' : 'text-md'"
class="
absolute
font-medium
text-gray-500
duration-300
transform
-translate-y-4
scale-75
top-1
z-10
origin-[0]
bg-white2
px-2
peer-focus:px-2 peer-focus:text-blue-600 peer-focus:dark:text-blue-500
peer-placeholder-shown:scale-100
peer-placeholder-shown:-translate-y-1/2
peer-placeholder-shown:top-1/2
peer-focus:top-2 peer-focus:scale-75 peer-focus:-translate-y-4
left-1
"
>{{ df.label }}</label
>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import Base from '../Controls/Base.vue';
export default defineComponent({
name: 'FloatingLabelInputBase',
extends: Base,
});
</script>

View File

@ -0,0 +1,166 @@
<template>
<Row
:ratio="ratio"
class="border flex items-center mt-4 px-2 rounded-t-md text-gray-600 w-full"
>
<div
v-for="df in tableFields"
:key="df.fieldname"
class="flex items-center px-2 py-2 text-lg"
:class="{
'ms-auto': isNumeric(df as Field),
}"
:style="{
height: ``,
}"
>
{{ df.label }}
</div>
</Row>
<div class="overflow-y-auto" style="height: 72.5vh">
<Row
v-if="items"
v-for="row in items"
:ratio="ratio"
:border="true"
class="
border-b border-l border-r
flex
group
h-row-mid
hover:bg-gray-25
items-center
justify-center
px-2
w-full
"
@click="handleChange(row as POSItem)"
>
<FormControl
v-for="df in tableFields"
:key="df.fieldname"
size="large"
class=""
:df="df"
:value="row[df.fieldname]"
:readOnly="true"
/>
</Row>
</div>
</template>
<script lang="ts">
import FormControl from '../Controls/FormControl.vue';
import Row from 'src/components/Row.vue';
import { isNumeric } from 'src/utils';
import { inject } from 'vue';
import { fyo } from 'src/initFyo';
import { defineComponent } from 'vue';
import { ModelNameEnum } from 'models/types';
import { Field } from 'schemas/types';
import { ItemQtyMap } from './types';
import { Item } from 'models/baseModels/Item/Item';
import { POSItem } from './types';
import { Money } from 'pesa';
export default defineComponent({
name: 'ItemsTable',
components: { FormControl, Row },
emits: ['addItem', 'updateValues'],
setup() {
return {
itemQtyMap: inject('itemQtyMap') as ItemQtyMap,
};
},
data() {
return {
items: [] as POSItem[],
};
},
computed: {
ratio() {
return [1, 1, 1, 0.7];
},
tableFields() {
return [
{
fieldname: 'name',
fieldtype: 'Data',
label: 'Item',
placeholder: 'Item',
readOnly: true,
},
{
fieldname: 'rate',
label: 'Rate',
placeholder: 'Rate',
fieldtype: 'Currency',
readOnly: true,
},
{
fieldname: 'availableQty',
label: 'Available Qty',
placeholder: 'Available Qty',
fieldtype: 'Float',
readOnly: true,
},
{
fieldname: 'unit',
label: 'Unit',
placeholder: 'Unit',
fieldtype: 'Data',
target: 'UOM',
readOnly: true,
},
] as Field[];
},
},
watch: {
itemQtyMap: {
async handler() {
this.setItems();
},
deep: true,
},
},
async activated() {
await this.setItems();
},
methods: {
async setItems() {
const items = (await fyo.db.getAll(ModelNameEnum.Item, {
fields: [],
filters: { trackItem: true },
})) as Item[];
this.items = [] as POSItem[];
for (const item of items) {
let availableQty = 0;
if (!!this.itemQtyMap[item.name as string]) {
availableQty = this.itemQtyMap[item.name as string].availableQty;
}
if (!item.name) {
return;
}
this.items.push({
availableQty,
name: item.name,
rate: item.rate as Money,
unit: item.unit as string,
hasBatch: !!item.hasBatch,
hasSerialNumber: !!item.hasSerialNumber,
});
}
},
handleChange(value: POSItem) {
this.$emit('addItem', value);
this.$emit('updateValues');
},
isNumeric,
},
});
</script>

View File

@ -0,0 +1,354 @@
<template>
<feather-icon
:name="isExapanded ? 'chevron-up' : 'chevron-down'"
class="w-4 h-4 inline-flex"
@click="isExapanded = !isExapanded"
/>
<Link
:df="{
fieldname: 'item',
fieldtype: 'Data',
label: 'item',
}"
size="small"
:border="false"
:value="row.item"
:read-only="true"
/>
<Int
:df="{
fieldname: 'quantity',
fieldtype: 'Int',
label: 'Quantity',
}"
size="small"
:border="false"
:value="row.quantity"
:read-only="true"
/>
<Link
:df="{
fieldname: 'unit',
fieldtype: 'Data',
label: 'Unit',
}"
size="small"
:border="false"
:value="row.unit"
:read-only="true"
/>
<Currency
:df="{
fieldtype: 'Currency',
fieldname: 'rate',
label: 'rate',
}"
size="small"
:border="false"
:value="row.rate"
:read-only="true"
/>
<Currency
:df="{
fieldtype: 'Currency',
fieldname: 'amount',
label: 'Amount',
}"
size="small"
:border="false"
:value="row.amount"
:read-only="true"
/>
<div class="px-4">
<feather-icon
name="trash"
class="w-4 text-xl text-red-500"
@click="$emit('removeItem', row.idx)"
/>
</div>
<div></div>
<template v-if="isExapanded">
<div class="px-4 pt-6 col-span-1">
<Float
:df="{
fieldname: 'quantity',
fieldtype: 'Float',
label: 'Quantity',
}"
size="medium"
:min="0"
:border="true"
:show-label="true"
:value="row.quantity"
@change="(value:number) => (row.quantity = value)"
:read-only="false"
/>
</div>
<div class="px-4 pt-6 col-span-2 flex">
<Link
v-if="isUOMConversionEnabled"
:df="{
fieldname: 'transferUnit',
fieldtype: 'Link',
target: 'UOM',
label: t`Transfer Unit`,
}"
class="flex-1"
:show-label="true"
:border="true"
:value="row.transferUnit"
@change="(value:string) => setTransferUnit((row.transferUnit = value))"
/>
<feather-icon
v-if="isUOMConversionEnabled"
name="refresh-ccw"
class="w-3.5 ml-2 mt-4 text-blue-500"
@click="row.transferUnit = row.unit"
/>
</div>
<div class="px-4 pt-6 col-span-2">
<Int
v-if="isUOMConversionEnabled"
:df="{
fieldtype: 'Int',
fieldname: 'transferQuantity',
label: 'Transfer Quantity',
}"
size="medium"
:border="true"
:show-label="true"
:value="row.transferQuantity"
@change="(value:number) => setTransferQty((row.transferQuantity = value))"
:read-only="false"
/>
</div>
<div></div>
<div></div>
<div class="px-4 pt-6 flex">
<Currency
:df="{
fieldtype: 'Currency',
fieldname: 'rate',
label: 'Rate',
}"
size="medium"
:show-label="true"
:border="true"
:value="row.rate"
:read-only="false"
@change="(value:Money) => (row.rate = value)"
/>
<feather-icon
name="refresh-ccw"
class="w-3.5 ml-2 mt-5 text-blue-500 flex-none"
@click="row.rate= (defaultRate as Money)"
/>
</div>
<div class="px-6 pt-6 col-span-2">
<Currency
v-if="isDiscountingEnabled"
:df="{
fieldtype: 'Currency',
fieldname: 'discountAmount',
label: 'Discount Amount',
}"
class="col-span-2"
size="medium"
:show-label="true"
:border="true"
:value="row.itemDiscountAmount"
:read-only="row.itemDiscountPercent as number > 0"
@change="(value:number) => setItemDiscount('amount', value)"
/>
</div>
<div class="px-4 pt-6 col-span-2">
<Float
v-if="isDiscountingEnabled"
:df="{
fieldtype: 'Float',
fieldname: 'itemDiscountPercent',
label: 'Discount Percent',
}"
size="medium"
:show-label="true"
:border="true"
:value="row.itemDiscountPercent"
:read-only="!row.itemDiscountAmount?.isZero()"
@change="(value:number) => setItemDiscount('percent', value)"
/>
</div>
<div class=""></div>
<div
v-if="row.links?.item && row.links?.item.hasBatch"
class="pl-6 px-4 pt-6 col-span-2"
>
<Link
:df="{
fieldname: 'batch',
fieldtype: 'Link',
target: 'Batch',
label: t`Batch`,
}"
value=""
:border="true"
:show-label="true"
:read-only="false"
@change="(value:string) => setBatch(value)"
/>
</div>
<div
v-if="row.links?.item && row.links?.item.hasBatch"
class="px-2 pt-6 col-span-2"
>
<Float
:df="{
fieldname: 'availableQtyInBatch',
fieldtype: 'Float',
label: t`Qty in Batch`,
}"
size="medium"
:min="0"
:value="availableQtyInBatch"
:show-label="true"
:border="true"
:read-only="true"
:text-right="true"
/>
</div>
<div v-if="hasSerialNumber" class="px-2 pt-8 col-span-2">
<Text
:df="{
label: t`Serial Number`,
fieldtype: 'Text',
fieldname: 'serialNumber',
}"
:value="row.serialNumber"
:show-label="true"
:border="true"
:required="hasSerialNumber"
@change="(value:string)=> setSerialNumber(value)"
/>
</div>
</template>
</template>
<script lang="ts">
import Currency from '../Controls/Currency.vue';
import Data from '../Controls/Data.vue';
import Float from '../Controls/Float.vue';
import Int from '../Controls/Int.vue';
import Link from '../Controls/Link.vue';
import Text from '../Controls/Text.vue';
import { inject } from 'vue';
import { fyo } from 'src/initFyo';
import { defineComponent } from 'vue';
import { SalesInvoiceItem } from 'models/baseModels/SalesInvoiceItem/SalesInvoiceItem';
import { Money } from 'pesa';
import { DiscountType } from './types';
import { t } from 'fyo';
import { validateSerialNumberCount } from 'src/utils/pos';
export default defineComponent({
name: 'SelectedItemRow',
components: { Currency, Data, Float, Int, Link, Text },
props: {
row: { type: SalesInvoiceItem, required: true },
},
emits: ['removeItem', 'runSinvFormulas', 'setItemSerialNumbers'],
setup() {
return {
isDiscountingEnabled: inject('isDiscountingEnabled') as boolean,
itemSerialNumbers: inject('itemSerialNumbers') as {
[item: string]: string;
},
};
},
data() {
return {
isExapanded: false,
batches: [] as string[],
availableQtyInBatch: 0,
defaultRate: this.row.rate as Money,
};
},
computed: {
isUOMConversionEnabled(): boolean {
return !!fyo.singles.InventorySettings?.enableUomConversions;
},
hasSerialNumber(): boolean {
return !!(this.row.links?.item && this.row.links?.item.hasSerialNumber);
},
},
methods: {
async getAvailableQtyInBatch(): Promise<number> {
if (!this.row.batch) {
return 0;
}
return (
(await fyo.db.getStockQuantity(
this.row.item as string,
undefined,
undefined,
undefined,
this.row.batch
)) ?? 0
);
},
async setBatch(batch: string) {
this.row.batch = batch;
this.availableQtyInBatch = await this.getAvailableQtyInBatch();
},
setSerialNumber(serialNumber: string) {
if (!serialNumber) {
return;
}
this.itemSerialNumbers[this.row.item as string] = serialNumber;
validateSerialNumberCount(
serialNumber,
this.row.quantity ?? 0,
this.row.item!
);
},
setItemDiscount(type: DiscountType, value: Money | number) {
if (type === 'percent') {
this.row.setItemDiscountAmount = false;
this.row.itemDiscountPercent = value as number;
this.$emit('runSinvFormulas');
return;
}
this.row.setItemDiscountAmount = true;
this.row.itemDiscountAmount = value as Money;
this.$emit('runSinvFormulas');
},
setTransferUnit(unit: string) {
this.row.setTransferUnit = unit;
this.row._applyFormula('transferUnit');
},
setTransferQty(quantity: number) {
this.row.transferQuantity = quantity;
this.row._applyFormula('transferQuantity');
this.$emit('runSinvFormulas');
},
},
});
</script>

View File

@ -0,0 +1,150 @@
<template>
<Row
:ratio="ratio"
class="border rounded-t px-2 text-gray-600 w-full flex items-center mt-4"
>
<div
v-if="tableFields"
v-for="df in tableFields"
:key="df.fieldname"
class="items-center text-lg flex px-2 py-2"
:class="{
'ms-auto': isNumeric(df as Field),
}"
:style="{
height: ``,
}"
>
{{ df.label }}
</div>
</Row>
<div class="overflow-y-auto" style="height: 50vh">
<Row
v-for="row in sinvDoc.items"
:ratio="ratio"
class="
border
w-full
px-2
py-2
group
flex
items-center
justify-center
hover:bg-gray-25
"
>
<SelectedItemRow
:row="(row as SalesInvoiceItem)"
@remove-item="removeItem"
@run-sinv-formulas="runSinvFormulas"
/>
</Row>
</div>
</template>
<script lang="ts">
import FormContainer from '../FormContainer.vue';
import FormControl from '../Controls/FormControl.vue';
import Link from '../Controls/Link.vue';
import Row from '../Row.vue';
import RowEditForm from 'src/pages/CommonForm/RowEditForm.vue';
import SelectedItemRow from './SelectedItemRow.vue';
import { isNumeric } from 'src/utils';
import { inject } from 'vue';
import { defineComponent } from 'vue';
import { SalesInvoiceItem } from 'models/baseModels/SalesInvoiceItem/SalesInvoiceItem';
import { SalesInvoice } from 'models/baseModels/SalesInvoice/SalesInvoice';
import { Field } from 'schemas/types';
export default defineComponent({
name: 'SelectedItemTable',
components: {
FormContainer,
FormControl,
Link,
Row,
RowEditForm,
SelectedItemRow,
},
setup() {
return {
sinvDoc: inject('sinvDoc') as SalesInvoice,
};
},
data() {
return {
isExapanded: false,
};
},
computed: {
ratio() {
return [0.1, 1, 0.8, 0.8, 0.8, 0.8, 0.2];
},
tableFields() {
return [
{
fieldname: 'toggler',
fieldtype: 'Link',
label: ' ',
},
{
fieldname: 'item',
fieldtype: 'Link',
label: 'Item',
placeholder: 'Item',
required: true,
schemaName: 'Item',
},
{
fieldname: 'quantity',
label: 'Quantity',
placeholder: 'Quantity',
fieldtype: 'Int',
required: true,
schemaName: '',
},
{
fieldname: 'unit',
label: 'Stock Unit',
placeholder: 'Unit',
fieldtype: 'Link',
required: true,
schemaName: 'UOM',
},
{
fieldname: 'rate',
label: 'Rate',
placeholder: 'Rate',
fieldtype: 'Currency',
required: true,
schemaName: '',
},
{
fieldname: 'amount',
label: 'Amount',
placeholder: 'Amount',
fieldtype: 'Currency',
required: true,
schemaName: '',
},
{
fieldname: 'removeItem',
fieldtype: 'Link',
label: ' ',
},
];
},
},
methods: {
removeItem(idx: number) {
this.sinvDoc.remove('items', idx);
},
async runSinvFormulas() {
await this.sinvDoc.runFormulas();
},
isNumeric,
},
});
</script>

View File

@ -0,0 +1,20 @@
import { Money } from "pesa";
export type ItemQtyMap = {
[item: string]: { availableQty: number;[batch: string]: number };
}
export type ItemSerialNumbers = { [item: string]: string };
export type DiscountType = "percent" | "amount";
export type ModalName = 'ShiftOpen' | 'ShiftClose' | 'Payment'
export interface POSItem {
name: string,
rate: Money,
availableQty: number,
unit: string,
hasBatch: boolean,
hasSerialNumber: boolean,
}

View File

@ -0,0 +1,209 @@
<template>
<Modal :open-modal="openModal" class="w-3/6 p-4">
<h1 class="text-xl font-semibold text-center pb-4">Close POS Shift</h1>
<h2 class="mt-4 mb-2 text-lg font-medium">Closing Cash</h2>
<Table
v-if="isValuesSeeded"
class="text-base"
:df="getField('closingCash')"
:show-header="true"
:border="true"
:value="posShiftDoc?.closingCash ?? []"
:read-only="false"
@row-change="handleChange"
/>
<h2 class="mt-6 mb-2 text-lg font-medium">Closing Amounts</h2>
<Table
v-if="isValuesSeeded"
class="text-base"
:df="getField('closingAmounts')"
:show-header="true"
:border="true"
:value="posShiftDoc?.closingAmounts"
:read-only="true"
@row-change="handleChange"
/>
<div class="mt-4 grid grid-cols-2 gap-4 flex items-end">
<Button
class="w-full py-5 bg-red-500"
@click="$emit('toggleModal', 'ShiftClose', false)"
>
<slot>
<p class="uppercase text-lg text-white font-semibold">
{{ t`Cancel` }}
</p>
</slot>
</Button>
<Button class="w-full py-5 bg-green-500" @click="handleSubmit">
<slot>
<p class="uppercase text-lg text-white font-semibold">
{{ t`Submit` }}
</p>
</slot>
</Button>
</div>
</Modal>
</template>
<script lang="ts">
import Button from 'src/components/Button.vue';
import Modal from 'src/components/Modal.vue';
import Table from 'src/components/Controls/Table.vue';
import { ModelNameEnum } from 'models/types';
import { Money } from 'pesa';
import { OpeningAmounts } from 'models/inventory/Point of Sale/OpeningAmounts';
import { POSShift } from 'models/inventory/Point of Sale/POSShift';
import { computed } from 'vue';
import { defineComponent } from 'vue';
import { fyo } from 'src/initFyo';
import { showToast } from 'src/utils/interactive';
import { t } from 'fyo';
import {
validateClosingAmounts,
transferPOSCashAndWriteOff,
} from 'src/utils/pos';
export default defineComponent({
name: 'ClosePOSShiftModal',
components: { Button, Modal, Table },
provide() {
return {
doc: computed(() => this.posShiftDoc),
};
},
props: {
openModal: {
default: false,
type: Boolean,
},
},
emits: ['toggleModal'],
data() {
return {
isValuesSeeded: false,
posShiftDoc: undefined as POSShift | undefined,
transactedAmount: {} as Record<string, Money> | undefined,
};
},
watch: {
openModal: {
async handler() {
await this.setTransactedAmount();
await this.seedClosingAmounts();
},
},
},
async activated() {
this.posShiftDoc = fyo.singles[ModelNameEnum.POSShift];
await this.seedValues();
await this.setTransactedAmount();
},
methods: {
async setTransactedAmount() {
if (!fyo.singles.POSShift?.openingDate) {
return;
}
const fromDate = fyo.singles.POSShift?.openingDate;
this.transactedAmount = await fyo.db.getPOSTransactedAmount(
fromDate,
new Date(),
fyo.singles.POSShift.closingDate as Date
);
},
seedClosingCash() {
if (!this.posShiftDoc) {
return;
}
this.posShiftDoc.closingCash = [];
this.posShiftDoc?.openingCash?.map(async (row) => {
await this.posShiftDoc?.append('closingCash', {
count: row.count,
denomination: row.denomination as Money,
});
});
},
async seedClosingAmounts() {
if (!this.posShiftDoc) {
return;
}
this.posShiftDoc.closingAmounts = [];
await this.posShiftDoc.sync();
const openingAmounts = this.posShiftDoc
.openingAmounts as OpeningAmounts[];
for (const row of openingAmounts) {
if (!row.paymentMethod) {
return;
}
let expectedAmount = fyo.pesa(0);
if (row.paymentMethod === 'Cash') {
expectedAmount = expectedAmount.add(
this.posShiftDoc.openingCashAmount as Money
);
}
if (row.paymentMethod === 'Transfer') {
expectedAmount = expectedAmount.add(
this.posShiftDoc.openingTransferAmount as Money
);
}
if (this.transactedAmount) {
expectedAmount = expectedAmount.add(
this.transactedAmount[row.paymentMethod]
);
}
await this.posShiftDoc.append('closingAmounts', {
paymentMethod: row.paymentMethod,
openingAmount: row.amount,
closingAmount: fyo.pesa(0),
expectedAmount: expectedAmount,
differenceAmount: fyo.pesa(0),
});
await this.posShiftDoc.sync();
}
},
async seedValues() {
this.isValuesSeeded = false;
this.seedClosingCash();
await this.seedClosingAmounts();
this.isValuesSeeded = true;
},
getField(fieldname: string) {
return fyo.getField(ModelNameEnum.POSShift, fieldname);
},
async handleChange() {
await this.posShiftDoc?.sync();
},
async handleSubmit() {
try {
validateClosingAmounts(this.posShiftDoc as POSShift);
await this.posShiftDoc?.set('isShiftOpen', false);
await this.posShiftDoc?.set('closingDate', new Date());
await this.posShiftDoc?.sync();
await transferPOSCashAndWriteOff(fyo, this.posShiftDoc as POSShift);
this.$emit('toggleModal', 'ShiftClose');
} catch (error) {
return showToast({
type: 'error',
message: t`${error as string}`,
duration: 'short',
});
}
},
},
});
</script>

View File

@ -0,0 +1,223 @@
<template>
<Modal class="w-3/6 p-4">
<h1 class="text-xl font-semibold text-center pb-4">Open POS Shift</h1>
<div class="grid grid-cols-12 gap-6">
<div class="col-span-6">
<h2 class="text-lg font-medium">Cash In Denominations</h2>
<Table
v-if="isValuesSeeded"
class="mt-4 text-base"
:df="getField('openingCash')"
:show-header="true"
:border="true"
:value="posShiftDoc?.openingCash"
@row-change="handleChange"
/>
</div>
<div class="col-span-6">
<h2 class="text-lg font-medium">Opening Amount</h2>
<Table
v-if="isValuesSeeded"
class="mt-4 text-base"
:df="getField('openingAmounts')"
:show-header="true"
:border="true"
:max-rows-before-overflow="4"
:value="posShiftDoc?.openingAmounts"
:read-only="true"
@row-change="handleChange"
/>
<div class="mt-4 grid grid-cols-2 gap-4 flex items-end">
<Button class="w-full py-5 bg-red-500" @click="$router.back()">
<slot>
<p class="uppercase text-lg text-white font-semibold">
{{ t`Back` }}
</p>
</slot>
</Button>
<Button class="w-full py-5 bg-green-500" @click="handleSubmit">
<slot>
<p class="uppercase text-lg text-white font-semibold">
{{ t`Submit` }}
</p>
</slot>
</Button>
</div>
</div>
</div>
</Modal>
</template>
<script lang="ts">
import Button from 'src/components/Button.vue';
import Modal from 'src/components/Modal.vue';
import Table from 'src/components/Controls/Table.vue';
import { AccountTypeEnum } from 'models/baseModels/Account/types';
import { ModelNameEnum } from 'models/types';
import { Money } from 'pesa';
import { POSShift } from 'models/inventory/Point of Sale/POSShift';
import { computed } from 'vue';
import { defineComponent } from 'vue';
import { fyo } from 'src/initFyo';
import { showToast } from 'src/utils/interactive';
import { t } from 'fyo';
import { ValidationError } from 'fyo/utils/errors';
export default defineComponent({
name: 'OpenPOSShift',
components: { Button, Modal, Table },
provide() {
return {
doc: computed(() => this.posShiftDoc),
};
},
emits: ['toggleModal'],
data() {
return {
posShiftDoc: undefined as POSShift | undefined,
isValuesSeeded: false,
};
},
computed: {
getDefaultCashDenominations() {
return this.fyo.singles.Defaults?.posCashDenominations;
},
posCashAccount() {
return fyo.singles.POSSettings?.cashAccount;
},
posOpeningCashAmount(): Money {
return this.posShiftDoc?.openingCashAmount as Money;
},
},
async mounted() {
this.isValuesSeeded = false;
this.posShiftDoc = fyo.singles[ModelNameEnum.POSShift];
await this.seedDefaults();
this.isValuesSeeded = true;
},
methods: {
async seedDefaultCashDenomiations() {
if (!this.posShiftDoc) {
return;
}
this.posShiftDoc.openingCash = [];
await this.posShiftDoc.sync();
const denominations = this.getDefaultCashDenominations;
if (!denominations) {
return;
}
for (const row of denominations) {
await this.posShiftDoc.append('openingCash', {
denomination: row.denomination,
count: 0,
});
await this.posShiftDoc.sync();
}
},
async seedPaymentMethods() {
if (!this.posShiftDoc) {
return;
}
this.posShiftDoc.openingAmounts = [];
await this.posShiftDoc.sync();
await this.posShiftDoc.set('openingAmounts', [
{
paymentMethod: 'Cash',
amount: fyo.pesa(0),
},
{
paymentMethod: 'Transfer',
amount: fyo.pesa(0),
},
]);
await this.posShiftDoc.sync();
},
async seedDefaults() {
if (!!this.posShiftDoc?.isShiftOpen) {
return;
}
await this.seedDefaultCashDenomiations();
await this.seedPaymentMethods();
},
getField(fieldname: string) {
return this.fyo.getField(ModelNameEnum.POSShift, fieldname);
},
setOpeningCashAmount() {
if (!this.posShiftDoc?.openingAmounts) {
return;
}
this.posShiftDoc.openingAmounts.map((row) => {
if (row.paymentMethod === 'Cash') {
row.amount = this.posShiftDoc?.openingCashAmount as Money;
}
});
},
async handleChange() {
await this.posShiftDoc?.sync();
this.setOpeningCashAmount();
},
async handleSubmit() {
try {
if (this.posShiftDoc?.openingCashAmount.isNegative()) {
throw new ValidationError(
t`Opening Cash Amount can not be negative.`
);
}
await this.posShiftDoc?.setMultiple({
isShiftOpen: true,
openingDate: new Date(),
});
await this.posShiftDoc?.sync();
if (!this.posShiftDoc?.openingCashAmount.isZero()) {
const jvDoc = fyo.doc.getNewDoc(ModelNameEnum.JournalEntry, {
entryType: 'Journal Entry',
});
await jvDoc.append('accounts', {
account: this.posCashAccount,
debit: this.posShiftDoc?.openingCashAmount as Money,
credit: this.fyo.pesa(0),
});
await jvDoc.append('accounts', {
account: AccountTypeEnum.Cash,
debit: this.fyo.pesa(0),
credit: this.posShiftDoc?.openingCashAmount as Money,
});
await (await jvDoc.sync()).submit();
}
this.$emit('toggleModal', 'ShiftOpen');
} catch (error) {
showToast({
type: 'error',
message: t`${error as string}`,
duration: 'short',
});
return;
}
},
},
});
</script>

550
src/pages/POS/POS.vue Normal file
View File

@ -0,0 +1,550 @@
<template>
<div class="">
<PageHeader :title="t`Point of Sale`">
<slot>
<Button class="bg-red-500" @click="toggleModal('ShiftClose')">
<span class="font-medium text-white">{{ t`Close POS Shift ` }}</span>
</Button>
</slot>
</PageHeader>
<OpenPOSShiftModal
v-if="!isPosShiftOpen"
:open-modal="!isPosShiftOpen"
@toggle-modal="toggleModal"
/>
<ClosePOSShiftModal
:open-modal="openShiftCloseModal"
@toggle-modal="toggleModal"
/>
<PaymentModal
:open-modal="openPaymentModal"
@create-transaction="createTransaction"
@toggle-modal="toggleModal"
@set-cash-amount="setCashAmount"
@set-transfer-amount="setTransferAmount"
@set-transfer-ref-no="setTransferRefNo"
@set-transfer-clearance-date="setTransferClearanceDate"
/>
<div
class="bg-gray-25 gap-2 grid grid-cols-12 p-4"
style="height: calc(100vh - var(--h-row-largest))"
>
<div class="bg-white border col-span-5 rounded-md">
<div class="rounded-md p-4 col-span-5">
<!-- Item Search -->
<Link
class="border-r flex-shrink-0 w-full"
:df="{
label: t`Search an Item`,
fieldtype: 'Link',
fieldname: 'item',
target: 'Item',
}"
:border="true"
:value="itemSearchTerm"
@keyup.enter="
async () => await addItem(await getItem(itemSearchTerm))
"
@change="(item: string) =>itemSearchTerm= item"
/>
<ItemsTable @add-item="addItem" />
</div>
</div>
<div class="col-span-7">
<div class="flex flex-col gap-3" style="height: calc(100vh - 6rem)">
<div class="bg-white border grow h-full p-4 rounded-md">
<!-- Customer Search -->
<Link
v-if="sinvDoc.fieldMap"
class="flex-shrink-0"
:border="true"
:value="sinvDoc.party"
:df="sinvDoc.fieldMap.party"
@change="(value:string) => (sinvDoc.party = value)"
/>
<SelectedItemTable />
</div>
<div class="bg-white border p-4 rounded-md">
<div class="w-full grid grid-cols-2 gap-y-2 gap-x-3">
<div class="">
<div class="grid grid-cols-2 gap-2">
<FloatingLabelFloatInput
:df="{
label: t`Total Quantity`,
fieldtype: 'Int',
fieldname: 'totalQuantity',
minvalue: 0,
maxvalue: 1000,
}"
size="large"
:value="totalQuantity"
:read-only="true"
:text-right="true"
/>
<FloatingLabelCurrencyInput
:df="{
label: t`Add'l Discounts`,
fieldtype: 'Int',
fieldname: 'additionalDiscount',
minvalue: 0,
}"
size="large"
:value="additionalDiscounts"
:read-only="true"
:text-right="true"
@change="(amount:Money)=> additionalDiscounts= amount"
/>
</div>
<div class="mt-4 grid grid-cols-2 gap-2">
<FloatingLabelCurrencyInput
:df="{
label: t`Item Discounts`,
fieldtype: 'Currency',
fieldname: 'itemDiscounts',
}"
size="large"
:value="itemDiscounts"
:read-only="true"
:text-right="true"
/>
<FloatingLabelCurrencyInput
v-if="sinvDoc.fieldMap"
:df="sinvDoc.fieldMap.grandTotal"
size="large"
:value="sinvDoc.grandTotal"
:read-only="true"
:text-right="true"
/>
</div>
</div>
<div class="">
<Button
class="w-full bg-red-500 py-6"
:disabled="!sinvDoc.items?.length"
@click="clearValues"
>
<slot>
<p class="uppercase text-lg text-white font-semibold">
{{ t`Cancel` }}
</p>
</slot>
</Button>
<Button
class="mt-4 w-full bg-green-500 py-6"
:disabled="disablePayButton"
@click="toggleModal('Payment', true)"
>
<slot>
<p class="uppercase text-lg text-white font-semibold">
{{ t`Pay` }}
</p>
</slot>
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Button from 'src/components/Button.vue';
import ClosePOSShiftModal from './ClosePOSShiftModal.vue';
import FloatingLabelCurrencyInput from 'src/components/POS/FloatingLabelCurrencyInput.vue';
import FloatingLabelFloatInput from 'src/components/POS/FloatingLabelFloatInput.vue';
import ItemsTable from 'src/components/POS/ItemsTable.vue';
import Link from 'src/components/Controls/Link.vue';
import OpenPOSShiftModal from './OpenPOSShiftModal.vue';
import PageHeader from 'src/components/PageHeader.vue';
import PaymentModal from './PaymentModal.vue';
import SelectedItemTable from 'src/components/POS/SelectedItemTable.vue';
import { computed, defineComponent } from 'vue';
import { fyo } from 'src/initFyo';
import { routeTo, toggleSidebar } from 'src/utils/ui';
import { ModelNameEnum } from 'models/types';
import { SalesInvoice } from 'models/baseModels/SalesInvoice/SalesInvoice';
import { t } from 'fyo';
import {
ItemQtyMap,
ItemSerialNumbers,
POSItem,
} from 'src/components/POS/types';
import { Item } from 'models/baseModels/Item/Item';
import { ModalName } from 'src/components/POS/types';
import { Money } from 'pesa';
import { Payment } from 'models/baseModels/Payment/Payment';
import { SalesInvoiceItem } from 'models/baseModels/SalesInvoiceItem/SalesInvoiceItem';
import { Shipment } from 'models/inventory/Shipment';
import { showToast } from 'src/utils/interactive';
import {
getItem,
getItemDiscounts,
getItemQtyMap,
getTotalQuantity,
getTotalTaxedAmount,
validateIsPosSettingsSet,
validateShipment,
validateSinv,
} from 'src/utils/pos';
export default defineComponent({
name: 'POS',
components: {
Button,
ClosePOSShiftModal,
FloatingLabelCurrencyInput,
FloatingLabelFloatInput,
ItemsTable,
Link,
OpenPOSShiftModal,
PageHeader,
PaymentModal,
SelectedItemTable,
},
provide() {
return {
cashAmount: computed(() => this.cashAmount),
doc: computed(() => this.sinvDoc),
isDiscountingEnabled: computed(() => this.isDiscountingEnabled),
itemDiscounts: computed(() => this.itemDiscounts),
itemQtyMap: computed(() => this.itemQtyMap),
itemSerialNumbers: computed(() => this.itemSerialNumbers),
sinvDoc: computed(() => this.sinvDoc),
totalTaxedAmount: computed(() => this.totalTaxedAmount),
transferAmount: computed(() => this.transferAmount),
transferClearanceDate: computed(() => this.transferClearanceDate),
transferRefNo: computed(() => this.transferRefNo),
};
},
data() {
return {
isItemsSeeded: false,
openPaymentModal: false,
openShiftCloseModal: false,
openShiftOpenModal: false,
additionalDiscounts: fyo.pesa(0),
cashAmount: fyo.pesa(0),
itemDiscounts: fyo.pesa(0),
totalTaxedAmount: fyo.pesa(0),
transferAmount: fyo.pesa(0),
totalQuantity: 0,
defaultCustomer: undefined as string | undefined,
itemSearchTerm: '',
transferRefNo: undefined as string | undefined,
transferClearanceDate: undefined as Date | undefined,
itemQtyMap: {} as ItemQtyMap,
itemSerialNumbers: {} as ItemSerialNumbers,
paymentDoc: {} as Payment,
sinvDoc: {} as SalesInvoice,
};
},
computed: {
defaultPOSCashAccount: () =>
fyo.singles.POSSettings?.cashAccount ?? undefined,
isDiscountingEnabled(): boolean {
return !!fyo.singles.AccountingSettings?.enableDiscounting;
},
isPosShiftOpen: () => !!fyo.singles.POSShift?.isShiftOpen,
isPaymentAmountSet(): boolean {
if (this.sinvDoc.grandTotal?.isZero()) {
return true;
}
if (this.cashAmount.isZero() && this.transferAmount.isZero()) {
return false;
}
return true;
},
disablePayButton(): boolean {
if (!this.sinvDoc.items?.length) {
return true;
}
if (!this.sinvDoc.party) {
return true;
}
return false;
},
},
watch: {
sinvDoc: {
handler() {
this.updateValues();
},
deep: true,
},
},
async activated() {
toggleSidebar(false);
validateIsPosSettingsSet(fyo);
this.setSinvDoc();
this.setDefaultCustomer();
await this.setItemQtyMap();
},
deactivated() {
toggleSidebar(true);
},
methods: {
setCashAmount(amount: Money) {
this.cashAmount = amount;
},
setDefaultCustomer() {
this.defaultCustomer = this.fyo.singles.Defaults?.posCustomer ?? '';
this.sinvDoc.party = this.defaultCustomer;
},
setItemDiscounts() {
this.itemDiscounts = getItemDiscounts(
this.sinvDoc.items as SalesInvoiceItem[]
);
},
async setItemQtyMap() {
this.itemQtyMap = await getItemQtyMap();
},
setSinvDoc() {
this.sinvDoc = this.fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, {
account: 'Debtors',
party: this.sinvDoc.party ?? this.defaultCustomer,
isPOS: true,
}) as SalesInvoice;
},
setTotalQuantity() {
this.totalQuantity = getTotalQuantity(
this.sinvDoc.items as SalesInvoiceItem[]
);
},
setTotalTaxedAmount() {
this.totalTaxedAmount = getTotalTaxedAmount(this.sinvDoc as SalesInvoice);
},
setTransferAmount(amount: Money = fyo.pesa(0)) {
this.transferAmount = amount;
},
setTransferClearanceDate(date: Date) {
this.transferClearanceDate = date;
},
setTransferRefNo(ref: string) {
this.transferRefNo = ref;
},
async addItem(item: POSItem | Item | undefined) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.sinvDoc.runFormulas();
if (!item) {
return;
}
if (
!this.itemQtyMap[item.name as string] ||
this.itemQtyMap[item.name as string].availableQty === 0
) {
showToast({
type: 'error',
message: t`Item ${item.name as string} has Zero Quantity`,
duration: 'short',
});
return;
}
const existingItems =
this.sinvDoc.items?.filter(
(invoiceItem) => invoiceItem.item === item.name
) ?? [];
if (item.hasBatch) {
for (const item of existingItems) {
const itemQty = item.quantity ?? 0;
const qtyInBatch =
this.itemQtyMap[item.item as string][item.batch as string] ?? 0;
if (itemQty < qtyInBatch) {
item.quantity = (item.quantity as number) + 1;
return;
}
}
try {
await this.sinvDoc.append('items', {
rate: item.rate as Money,
item: item.name,
});
} catch (error) {
showToast({
type: 'error',
message: t`${error as string}`,
});
}
return;
}
if (existingItems.length) {
existingItems[0].quantity = (existingItems[0].quantity as number) + 1;
return;
}
await this.sinvDoc.append('items', {
rate: item.rate as Money,
item: item.name,
});
},
async createTransaction(shouldPrint = false) {
try {
await this.validate();
await this.submitSinvDoc(shouldPrint);
await this.makePayment();
await this.makeStockTransfer();
await this.afterTransaction();
} catch (error) {
showToast({
type: 'error',
message: t`${error as string}`,
});
}
},
async makePayment() {
this.paymentDoc = this.sinvDoc.getPayment() as Payment;
const paymentMethod = this.cashAmount.isZero() ? 'Transfer' : 'Cash';
await this.paymentDoc.set('paymentMethod', paymentMethod);
if (paymentMethod === 'Transfer') {
await this.paymentDoc.setMultiple({
amount: this.transferAmount as Money,
referenceId: this.transferRefNo,
clearanceDate: this.transferClearanceDate,
});
}
if (paymentMethod === 'Cash') {
await this.paymentDoc.setMultiple({
paymentAccount: this.defaultPOSCashAccount,
amount: this.cashAmount as Money,
});
}
this.paymentDoc.once('afterSubmit', () => {
showToast({
type: 'success',
message: t`Payment ${this.paymentDoc.name as string} is Saved`,
duration: 'short',
});
});
try {
await this.paymentDoc?.sync();
await this.paymentDoc?.submit();
} catch (error) {
return showToast({
type: 'error',
message: t`${error as string}`,
});
}
},
async makeStockTransfer() {
const shipmentDoc = (await this.sinvDoc.getStockTransfer()) as Shipment;
if (!shipmentDoc.items) {
return;
}
for (const item of shipmentDoc.items) {
item.location = fyo.singles.POSSettings?.inventory;
item.serialNumber =
this.itemSerialNumbers[item.item as string] ?? undefined;
}
shipmentDoc.once('afterSubmit', () => {
showToast({
type: 'success',
message: t`Shipment ${shipmentDoc.name as string} is Submitted`,
duration: 'short',
});
});
try {
await shipmentDoc.sync();
await shipmentDoc.submit();
} catch (error) {
return showToast({
type: 'error',
message: t`${error as string}`,
});
}
},
async submitSinvDoc(shouldPrint: boolean) {
this.sinvDoc.once('afterSubmit', async () => {
showToast({
type: 'success',
message: t`Sales Invoice ${this.sinvDoc.name as string} is Submitted`,
duration: 'short',
});
if (shouldPrint) {
await routeTo(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
`/print/${this.sinvDoc.schemaName}/${this.sinvDoc.name}`
);
}
});
try {
await this.validate();
await this.sinvDoc.runFormulas();
await this.sinvDoc.sync();
await this.sinvDoc.submit();
} catch (error) {
return showToast({
type: 'error',
message: t`${error as string}`,
});
}
},
async afterTransaction() {
await this.setItemQtyMap();
this.clearValues();
this.setSinvDoc();
this.toggleModal('Payment', false);
},
clearValues() {
this.setSinvDoc();
this.itemSerialNumbers = {};
this.cashAmount = fyo.pesa(0);
this.transferAmount = fyo.pesa(0);
},
toggleModal(modal: ModalName, value?: boolean) {
if (value) {
return (this[`open${modal}Modal`] = value);
}
return (this[`open${modal}Modal`] = !this[`open${modal}Modal`]);
},
updateValues() {
this.setTotalQuantity();
this.setItemDiscounts();
this.setTotalTaxedAmount();
},
async validate() {
validateSinv(this.sinvDoc as SalesInvoice, this.itemQtyMap);
await validateShipment(this.itemSerialNumbers);
},
getItem,
},
});
</script>

View File

@ -0,0 +1,325 @@
<template>
<Modal class="w-2/6 ml-auto mr-3.5" :set-close-listener="false">
<div v-if="sinvDoc.fieldMap" class="px-4 py-6 grid" style="height: 95vh">
<div class="grid grid-cols-2 gap-6">
<Currency
:df="fyo.fieldMap.PaymentFor.amount"
:read-only="!transferAmount.isZero()"
:border="true"
:text-right="true"
:value="cashAmount"
@change="(amount:Money)=> $emit('setCashAmount', amount)"
/>
<Button
class="w-full py-5 bg-teal-500"
@click="setCashOrTransferAmount"
>
<slot>
<p class="uppercase text-lg text-white font-semibold">
{{ t`Cash` }}
</p>
</slot>
</Button>
<Currency
:df="fyo.fieldMap.PaymentFor.amount"
:read-only="!cashAmount.isZero()"
:border="true"
:text-right="true"
:value="transferAmount"
@change="(value:Money)=> $emit('setTransferAmount', value)"
/>
<Button
class="w-full py-5 bg-teal-500"
@click="setCashOrTransferAmount('Transfer')"
>
<slot>
<p class="uppercase text-lg text-white font-semibold">
{{ t`Transfer` }}
</p>
</slot>
</Button>
</div>
<div class="mt-8 grid grid-cols-2 gap-6">
<Data
v-show="!transferAmount.isZero()"
:df="fyo.fieldMap.Payment.referenceId"
:show-label="true"
:border="true"
:required="!transferAmount.isZero()"
:value="transferRefNo"
@change="(value:string) => $emit('setTransferRefNo', value)"
/>
<Date
v-show="!transferAmount.isZero()"
:df="fyo.fieldMap.Payment.clearanceDate"
:show-label="true"
:border="true"
:required="!transferAmount.isZero()"
:value="transferClearanceDate"
@change="(value:Date) => $emit('setTransferClearanceDate', value)"
/>
</div>
<div class="mt-14 grid grid-cols-2 gap-6">
<Currency
v-show="showPaidChange"
:df="{
label: t`Paid Change`,
fieldtype: 'Currency',
fieldname: 'paidChange',
}"
:read-only="true"
:show-label="true"
:border="true"
:text-right="true"
:value="paidChange"
/>
<Currency
v-show="showBalanceAmount"
:df="{
label: t`Balance Amount`,
fieldtype: 'Currency',
fieldname: 'balanceAmount',
}"
:read-only="true"
:show-label="true"
:border="true"
:text-right="true"
:value="balanceAmount"
/>
</div>
<div
class="mb-14 row-start-4 row-span-2 grid grid-cols-2 gap-x-6 gap-y-11"
>
<Currency
:df="sinvDoc.fieldMap.netTotal"
:read-only="true"
:show-label="true"
:border="true"
:text-right="true"
:value="sinvDoc?.netTotal"
/>
<Currency
:df="{
label: t`Taxes and Charges`,
fieldtype: 'Currency',
fieldname: 'taxesAndCharges',
}"
:read-only="true"
:show-label="true"
:border="true"
:text-right="true"
:value="totalTaxedAmount"
/>
<Currency
:df="sinvDoc.fieldMap.baseGrandTotal"
:read-only="true"
:show-label="true"
:border="true"
:text-right="true"
:value="sinvDoc?.baseGrandTotal"
/>
<Currency
v-if="isDiscountingEnabled"
:df="sinvDoc.fieldMap.discountAmount"
:read-only="true"
:show-label="true"
:border="true"
:text-right="true"
:value="itemDiscounts"
/>
<Currency
:df="sinvDoc.fieldMap.grandTotal"
:read-only="true"
:show-label="true"
:border="true"
:text-right="true"
:value="sinvDoc?.grandTotal"
/>
</div>
<div class="row-start-6 grid grid-cols-2 gap-4 mt-auto">
<div class="col-span-2">
<Button
class="w-full bg-red-500"
style="padding: 1.35rem"
@click="$emit('toggleModal', 'Payment')"
>
<slot>
<p class="uppercase text-lg text-white font-semibold">
{{ t`Cancel` }}
</p>
</slot>
</Button>
</div>
<div class="col-span-1">
<Button
class="w-full bg-blue-500"
style="padding: 1.35rem"
:disabled="disableSubmitButton"
@click="$emit('createTransaction')"
>
<slot>
<p class="uppercase text-lg text-white font-semibold">
{{ t`Submit` }}
</p>
</slot>
</Button>
</div>
<div class="col-span-1">
<Button
class="w-full bg-green-500"
style="padding: 1.35rem"
:disabled="disableSubmitButton"
@click="$emit('createTransaction', true)"
>
<slot>
<p class="uppercase text-lg text-white font-semibold">
{{ t`Submit & Print` }}
</p>
</slot>
</Button>
</div>
</div>
</div>
</Modal>
</template>
<script lang="ts">
import Button from 'src/components/Button.vue';
import Currency from 'src/components/Controls/Currency.vue';
import Data from 'src/components/Controls/Data.vue';
import Date from 'src/components/Controls/Date.vue';
import Modal from 'src/components/Modal.vue';
import { Money } from 'pesa';
import { SalesInvoice } from 'models/baseModels/SalesInvoice/SalesInvoice';
import { defineComponent, inject } from 'vue';
import { fyo } from 'src/initFyo';
export default defineComponent({
name: 'PaymentModal',
components: {
Modal,
Currency,
Button,
Data,
Date,
},
emits: [
'createTransaction',
'setCashAmount',
'setTransferAmount',
'setTransferClearanceDate',
'setTransferRefNo',
'toggleModal',
],
setup() {
return {
cashAmount: inject('cashAmount') as Money,
isDiscountingEnabled: inject('isDiscountingEnabled') as boolean,
itemDiscounts: inject('itemDiscounts') as Money,
transferAmount: inject('transferAmount') as Money,
sinvDoc: inject('sinvDoc') as SalesInvoice,
transferRefNo: inject('transferRefNo') as string,
transferClearanceDate: inject('transferClearanceDate') as Date,
totalTaxedAmount: inject('totalTaxedAmount') as Money,
};
},
computed: {
balanceAmount(): Money {
const grandTotal = this.sinvDoc?.grandTotal ?? fyo.pesa(0);
if (this.cashAmount.isZero()) {
return grandTotal.sub(this.transferAmount);
}
return grandTotal.sub(this.cashAmount);
},
paidChange(): Money {
const grandTotal = this.sinvDoc?.grandTotal ?? fyo.pesa(0);
if (this.cashAmount.isZero()) {
return this.transferAmount.sub(grandTotal);
}
return this.cashAmount.sub(grandTotal);
},
showBalanceAmount(): boolean {
if (
this.cashAmount.eq(fyo.pesa(0)) &&
this.transferAmount.eq(fyo.pesa(0))
) {
return false;
}
if (this.cashAmount.gte(this.sinvDoc?.grandTotal ?? fyo.pesa(0))) {
return false;
}
if (this.transferAmount.gte(this.sinvDoc?.grandTotal ?? fyo.pesa(0))) {
return false;
}
return true;
},
showPaidChange(): boolean {
if (
this.cashAmount.eq(fyo.pesa(0)) &&
this.transferAmount.eq(fyo.pesa(0))
) {
return false;
}
if (this.cashAmount.gt(this.sinvDoc?.grandTotal ?? fyo.pesa(0))) {
return true;
}
if (this.transferAmount.gt(this.sinvDoc?.grandTotal ?? fyo.pesa(0))) {
return true;
}
return false;
},
disableSubmitButton(): boolean {
if (
!this.sinvDoc.grandTotal?.isZero() &&
this.transferAmount.isZero() &&
this.cashAmount.isZero()
) {
return true;
}
if (
this.cashAmount.isZero() &&
(!this.transferRefNo || !this.transferClearanceDate)
) {
return true;
}
return false;
},
},
methods: {
setCashOrTransferAmount(paymentMethod = 'Cash') {
if (paymentMethod === 'Transfer') {
this.$emit('setCashAmount', fyo.pesa(0));
this.$emit('setTransferAmount', this.sinvDoc?.grandTotal);
return;
}
this.$emit('setTransferAmount', fyo.pesa(0));
this.$emit('setCashAmount', this.sinvDoc?.grandTotal);
},
},
});
</script>

View File

@ -115,6 +115,7 @@ export default defineComponent({
ModelNameEnum.AccountingSettings,
ModelNameEnum.InventorySettings,
ModelNameEnum.Defaults,
ModelNameEnum.POSSettings,
ModelNameEnum.PrintSettings,
ModelNameEnum.SystemSettings,
].some((s) => this.fyo.singles[s]?.canSave);
@ -133,6 +134,7 @@ export default defineComponent({
[ModelNameEnum.PrintSettings]: this.t`Print`,
[ModelNameEnum.InventorySettings]: this.t`Inventory`,
[ModelNameEnum.Defaults]: this.t`Defaults`,
[ModelNameEnum.POSSettings]: this.t`POS Settings`,
[ModelNameEnum.SystemSettings]: this.t`System`,
};
},
@ -140,16 +142,26 @@ export default defineComponent({
const enableInventory =
!!this.fyo.singles.AccountingSettings?.enableInventory;
const enablePOS = !!this.fyo.singles.InventorySettings?.enablePointOfSale;
return [
ModelNameEnum.AccountingSettings,
ModelNameEnum.InventorySettings,
ModelNameEnum.Defaults,
ModelNameEnum.POSSettings,
ModelNameEnum.PrintSettings,
ModelNameEnum.SystemSettings,
]
.filter((s) =>
s === ModelNameEnum.InventorySettings ? enableInventory : true
)
.filter((s) => {
if (s === ModelNameEnum.InventorySettings && !enableInventory) {
return false;
}
if (s === ModelNameEnum.POSSettings && !enablePOS) {
return false;
}
return true;
})
.map((s) => this.fyo.schemaMap[s]!);
},
activeGroup(): Map<string, Field[]> {

View File

@ -11,6 +11,7 @@ import Report from 'src/pages/Report.vue';
import Settings from 'src/pages/Settings/Settings.vue';
import TemplateBuilder from 'src/pages/TemplateBuilder/TemplateBuilder.vue';
import CustomizeForm from 'src/pages/CustomizeForm/CustomizeForm.vue';
import POS from 'src/pages/POS/POS.vue';
import type { HistoryState } from 'vue-router';
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import { historyState } from './utils/refs';
@ -124,6 +125,18 @@ const routes: RouteRecordRaw[] = [
edit: (route) => route.query,
},
},
{
path: '/pos',
name: 'Point of Sale',
components: {
default: POS,
edit: QuickEditForm,
},
props: {
default: true,
edit: (route) => route.query,
},
},
];
const router = createRouter({ routes, history: createWebHistory() });

294
src/utils/pos.ts Normal file
View File

@ -0,0 +1,294 @@
import { Fyo, t } from 'fyo';
import { ValidationError } from 'fyo/utils/errors';
import { AccountTypeEnum } from 'models/baseModels/Account/types';
import { Item } from 'models/baseModels/Item/Item';
import { SalesInvoice } from 'models/baseModels/SalesInvoice/SalesInvoice';
import { SalesInvoiceItem } from 'models/baseModels/SalesInvoiceItem/SalesInvoiceItem';
import { POSShift } from 'models/inventory/Point of Sale/POSShift';
import { ValuationMethod } from 'models/inventory/types';
import { ModelNameEnum } from 'models/types';
import { Money } from 'pesa';
import {
getRawStockLedgerEntries,
getStockBalanceEntries,
getStockLedgerEntries,
} from 'reports/inventory/helpers';
import { ItemQtyMap, ItemSerialNumbers } from 'src/components/POS/types';
import { fyo } from 'src/initFyo';
import { safeParseFloat } from 'utils/index';
import { showToast } from './interactive';
export async function getItemQtyMap(): Promise<ItemQtyMap> {
const itemQtyMap: ItemQtyMap = {};
const valuationMethod =
fyo.singles.InventorySettings?.valuationMethod ?? ValuationMethod.FIFO;
const rawSLEs = await getRawStockLedgerEntries(fyo);
const rawData = getStockLedgerEntries(rawSLEs, valuationMethod);
const posInventory = fyo.singles.POSSettings?.inventory;
const stockBalance = getStockBalanceEntries(rawData, {
location: posInventory,
});
for (const row of stockBalance) {
if (!itemQtyMap[row.item]) {
itemQtyMap[row.item] = { availableQty: 0 };
}
if (row.batch) {
itemQtyMap[row.item][row.batch] = row.balanceQuantity;
}
itemQtyMap[row.item].availableQty += row.balanceQuantity;
}
return itemQtyMap;
}
export function getTotalQuantity(items: SalesInvoiceItem[]): number {
let totalQuantity = safeParseFloat(0);
if (!items.length) {
return totalQuantity;
}
for (const item of items) {
const quantity = item.quantity ?? 0;
totalQuantity = safeParseFloat(totalQuantity + quantity);
}
return totalQuantity;
}
export function getItemDiscounts(items: SalesInvoiceItem[]): Money {
let itemDiscounts = fyo.pesa(0);
if (!items.length) {
return itemDiscounts;
}
for (const item of items) {
if (!item.itemDiscountAmount?.isZero()) {
itemDiscounts = itemDiscounts.add(item.itemDiscountAmount as Money);
}
if (item.amount && (item.itemDiscountPercent as number) > 1) {
itemDiscounts = itemDiscounts.add(
item.amount.percent(item.itemDiscountPercent as number)
);
}
}
return itemDiscounts;
}
export async function getItem(item: string): Promise<Item | undefined> {
const itemDoc = (await fyo.doc.getDoc(ModelNameEnum.Item, item)) as Item;
if (!itemDoc) {
return;
}
return itemDoc;
}
export function validateSinv(sinvDoc: SalesInvoice, itemQtyMap: ItemQtyMap) {
if (!sinvDoc) {
return;
}
validateSinvItems(sinvDoc.items as SalesInvoiceItem[], itemQtyMap);
}
function validateSinvItems(
sinvItems: SalesInvoiceItem[],
itemQtyMap: ItemQtyMap
) {
for (const item of sinvItems) {
if (!item.quantity || item.quantity < 1) {
throw new ValidationError(
t`Invalid Quantity for Item ${item.item as string}`
);
}
if (!itemQtyMap[item.item as string]) {
throw new ValidationError(t`Item ${item.item as string} not in Stock`);
}
if (item.quantity > itemQtyMap[item.item as string].availableQty) {
throw new ValidationError(
t`Insufficient Quantity. Item ${item.item as string} has only ${
itemQtyMap[item.item as string].availableQty
} quantities available. you selected ${item.quantity}`
);
}
}
}
export async function validateShipment(itemSerialNumbers: ItemSerialNumbers) {
if (!itemSerialNumbers) {
return;
}
for (const idx in itemSerialNumbers) {
const serialNumbers = itemSerialNumbers[idx].split('\n');
for (const serialNumber of serialNumbers) {
const status = await fyo.getValue(
ModelNameEnum.SerialNumber,
serialNumber,
'status'
);
if (status !== 'Active') {
throw new ValidationError(
t`Serial Number ${serialNumber} status is not Active.`
);
}
}
}
}
export function validateIsPosSettingsSet(fyo: Fyo) {
try {
const inventory = fyo.singles.POSSettings?.inventory;
if (!inventory) {
throw new ValidationError(
t`POS Inventory is not set. Please set it on POS Settings`
);
}
const cashAccount = fyo.singles.POSSettings?.cashAccount;
if (!cashAccount) {
throw new ValidationError(
t`POS Counter Cash Account is not set. Please set it on POS Settings`
);
}
const writeOffAccount = fyo.singles.POSSettings?.writeOffAccount;
if (!writeOffAccount) {
throw new ValidationError(
t`POS Write Off Account is not set. Please set it on POS Settings`
);
}
} catch (error) {
showToast({
type: 'error',
message: t`${error as string}`,
duration: 'long',
});
}
}
export function getTotalTaxedAmount(sinvDoc: SalesInvoice): Money {
let totalTaxedAmount = fyo.pesa(0);
if (!sinvDoc.items?.length || !sinvDoc.taxes?.length) {
return totalTaxedAmount;
}
for (const row of sinvDoc.taxes) {
totalTaxedAmount = totalTaxedAmount.add(row.amount as Money);
}
return totalTaxedAmount;
}
export function validateClosingAmounts(posShiftDoc: POSShift) {
try {
if (!posShiftDoc) {
throw new ValidationError(
`POS Shift Document not loaded. Please reload.`
);
}
posShiftDoc.closingAmounts?.map((row) => {
if (row.closingAmount?.isNegative()) {
throw new ValidationError(
t`Closing ${row.paymentMethod as string} Amount can not be negative.`
);
}
});
} catch (error) {}
}
export async function transferPOSCashAndWriteOff(
fyo: Fyo,
posShiftDoc: POSShift
) {
const expectedCashAmount = posShiftDoc.closingAmounts?.find(
(row) => row.paymentMethod === 'Cash'
)?.expectedAmount as Money;
if (expectedCashAmount.isZero()) {
return;
}
const closingCashAmount = posShiftDoc.closingAmounts?.find(
(row) => row.paymentMethod === 'Cash'
)?.closingAmount as Money;
const jvDoc = fyo.doc.getNewDoc(ModelNameEnum.JournalEntry, {
entryType: 'Journal Entry',
});
await jvDoc.append('accounts', {
account: AccountTypeEnum.Cash,
debit: closingCashAmount,
});
await jvDoc.append('accounts', {
account: fyo.singles.POSSettings?.cashAccount,
credit: closingCashAmount,
});
const differenceAmount = posShiftDoc?.closingAmounts?.find(
(row) => row.paymentMethod === 'Cash'
)?.differenceAmount as Money;
if (differenceAmount.isNegative()) {
await jvDoc.append('accounts', {
account: AccountTypeEnum.Cash,
debit: differenceAmount.abs(),
credit: fyo.pesa(0),
});
await jvDoc.append('accounts', {
account: fyo.singles.POSSettings?.writeOffAccount,
debit: fyo.pesa(0),
credit: differenceAmount.abs(),
});
}
if (!differenceAmount.isZero() && differenceAmount.isPositive()) {
await jvDoc.append('accounts', {
account: fyo.singles.POSSettings?.writeOffAccount,
debit: differenceAmount,
credit: fyo.pesa(0),
});
await jvDoc.append('accounts', {
account: AccountTypeEnum.Cash,
debit: fyo.pesa(0),
credit: differenceAmount,
});
}
await (await jvDoc.sync()).submit();
}
export function validateSerialNumberCount(
serialNumbers: string | undefined,
quantity: number,
item: string
) {
let serialNumberCount = 0;
if (serialNumbers) {
serialNumberCount = serialNumbers.split('\n').length;
}
if (quantity !== serialNumberCount) {
const errorMessage = t`Need ${quantity} Serial Numbers for Item ${item}. You have provided ${serialNumberCount}`;
showToast({
type: 'error',
message: errorMessage,
duration: 'long',
});
throw new ValidationError(errorMessage);
}
}

View File

@ -101,6 +101,20 @@ function getInventorySidebar(): SidebarRoot[] {
];
}
function getPOSSidebar() {
const isPOSEnabled = !!fyo.singles.InventorySettings?.enablePointOfSale;
if (!isPOSEnabled) {
return [];
}
return {
label: t`POS`,
name: 'pos',
route: '/pos',
icon: 'pos',
};
}
function getReportSidebar() {
return {
label: t`Reports`,
@ -256,6 +270,7 @@ function getCompleteSidebar(): SidebarConfig {
},
getReportSidebar(),
getInventorySidebar(),
getPOSSidebar(),
getRegionalSidebar(),
{
label: t`Setup`,