2
0
mirror of https://github.com/frappe/books.git synced 2024-12-22 19:09:01 +00:00

feat: add view transfer/payments on invoices

This commit is contained in:
18alantom 2022-11-30 19:05:49 +05:30
parent 1f259524b9
commit bea9d86b91
13 changed files with 352 additions and 53 deletions

View File

@ -7,14 +7,14 @@ import {
DefaultMap, DefaultMap,
FiltersMap, FiltersMap,
FormulaMap, FormulaMap,
HiddenMap, HiddenMap
} from 'fyo/model/types'; } from 'fyo/model/types';
import { DEFAULT_CURRENCY } from 'fyo/utils/consts'; import { DEFAULT_CURRENCY } from 'fyo/utils/consts';
import { ValidationError } from 'fyo/utils/errors'; import { ValidationError } from 'fyo/utils/errors';
import { import {
getExchangeRate, getExchangeRate,
getInvoiceActions, getInvoiceActions,
getNumberSeries, getNumberSeries
} from 'models/helpers'; } from 'models/helpers';
import { InventorySettings } from 'models/inventory/InventorySettings'; import { InventorySettings } from 'models/inventory/InventorySettings';
import { StockTransfer } from 'models/inventory/StockTransfer'; import { StockTransfer } from 'models/inventory/StockTransfer';
@ -22,7 +22,10 @@ import { Transactional } from 'models/Transactional/Transactional';
import { ModelNameEnum } from 'models/types'; import { ModelNameEnum } from 'models/types';
import { Money } from 'pesa'; import { Money } from 'pesa';
import { FieldTypeEnum, Schema } from 'schemas/types'; import { FieldTypeEnum, Schema } from 'schemas/types';
import { getIsNullOrUndef, safeParseFloat } from 'utils'; import {
getIsNullOrUndef, joinMapLists,
safeParseFloat
} from 'utils';
import { Defaults } from '../Defaults/Defaults'; import { Defaults } from '../Defaults/Defaults';
import { InvoiceItem } from '../InvoiceItem/InvoiceItem'; import { InvoiceItem } from '../InvoiceItem/InvoiceItem';
import { Item } from '../Item/Item'; import { Item } from '../Item/Item';
@ -79,6 +82,22 @@ export abstract class Invoice extends Transactional {
: ModelNameEnum.PurchaseReceipt; : 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) { constructor(schema: Schema, data: DocValueMap, fyo: Fyo) {
super(schema, data, fyo); super(schema, data, fyo);
this._setGetCurrencies(); this._setGetCurrencies();
@ -369,6 +388,14 @@ export abstract class Invoice extends Transactional {
}, },
}; };
getStockTransferred() {
return (this.items ?? []).reduce(
(acc, item) =>
(item.quantity ?? 0) - (item.stockNotTransferred ?? 0) + acc,
0
);
}
getStockNotTransferred() { getStockNotTransferred() {
return (this.items ?? []).reduce( return (this.items ?? []).reduce(
(acc, item) => (item.stockNotTransferred ?? 0) + acc, (acc, item) => (item.stockNotTransferred ?? 0) + acc,
@ -597,4 +624,78 @@ export abstract class Invoice extends Transactional {
})) as { name: string }[]; })) as { name: string }[];
return transfers; 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

@ -10,8 +10,7 @@ import { DateTime } from 'luxon';
import { import {
getDocStatus, getDocStatus,
getLedgerLinkAction, getLedgerLinkAction,
getNumberSeries, getNumberSeries, getStatusText,
getStatusMap,
statusColor statusColor
} from 'models/helpers'; } from 'models/helpers';
import { Transactional } from 'models/Transactional/Transactional'; import { Transactional } from 'models/Transactional/Transactional';
@ -64,7 +63,7 @@ export class JournalEntry extends Transactional {
render(doc) { render(doc) {
const status = getDocStatus(doc); const status = getDocStatus(doc);
const color = statusColor[status] ?? 'gray'; const color = statusColor[status] ?? 'gray';
const label = getStatusMap()[status]; const label = getStatusText(status);
return { return {
template: `<Badge class="text-xs" color="${color}">${label}</Badge>`, template: `<Badge class="text-xs" color="${color}">${label}</Badge>`,

View File

@ -99,8 +99,6 @@ export function getLedgerLinkAction(
} }
export function getTransactionStatusColumn(): ColumnConfig { export function getTransactionStatusColumn(): ColumnConfig {
const statusMap = getStatusMap();
return { return {
label: t`Status`, label: t`Status`,
fieldname: 'status', fieldname: 'status',
@ -108,7 +106,7 @@ export function getTransactionStatusColumn(): ColumnConfig {
render(doc) { render(doc) {
const status = getDocStatus(doc) as InvoiceStatus; const status = getDocStatus(doc) as InvoiceStatus;
const color = statusColor[status]; const color = statusColor[status];
const label = statusMap[status]; const label = getStatusText(status);
return { return {
template: `<Badge class="text-xs" color="${color}">${label}</Badge>`, template: `<Badge class="text-xs" color="${color}">${label}</Badge>`,
@ -131,17 +129,25 @@ export const statusColor: Record<
Cancelled: 'red', Cancelled: 'red',
}; };
export function getStatusMap(): Record<DocStatus | InvoiceStatus, string> { export function getStatusText(status: DocStatus | InvoiceStatus): string {
return { switch (status) {
'': '', case 'Draft':
Draft: t`Draft`, return t`Draft`;
Unpaid: t`Unpaid`, case 'Saved':
Paid: t`Paid`, return t`Saved`;
Saved: t`Saved`, case 'NotSaved':
NotSaved: t`Not Saved`, return t`NotSaved`;
Submitted: t`Submitted`, case 'Submitted':
Cancelled: t`Cancelled`, return t`Submitted`;
}; case 'Cancelled':
return t`Cancelled`;
case 'Paid':
return t`Paid`;
case 'Unpaid':
return t`Unpaid`;
default:
return '';
}
} }
export function getDocStatus( export function getDocStatus(
@ -296,7 +302,7 @@ export function getDocStatusListColumn(): ColumnConfig {
render(doc) { render(doc) {
const status = getDocStatus(doc); const status = getDocStatus(doc);
const color = statusColor[status] ?? 'gray'; const color = statusColor[status] ?? 'gray';
const label = getStatusMap()[status]; const label = getStatusText(status);
return { return {
template: `<Badge class="text-xs" color="${color}">${label}</Badge>`, template: `<Badge class="text-xs" color="${color}">${label}</Badge>`,

View File

@ -1,22 +1,25 @@
<template> <template>
<Badge class="text-sm flex-center px-3 ml-2" :color="color" v-if="status">{{ <Badge
statusLabel class="flex-center"
}}</Badge> :color="color"
v-if="status"
:class="defaultSize ? 'text-sm px-3' : ''"
>{{ statusLabel }}</Badge
>
</template> </template>
<script> <script>
import { getStatusMap, statusColor } from 'models/helpers'; import { getStatusText, statusColor } from 'models/helpers';
import Badge from './Badge.vue'; import Badge from './Badge.vue';
export default { export default {
name: 'StatusBadge', name: 'StatusBadge',
props: ['status'], props: { status: String, defaultSize: { type: Boolean, default: true } },
computed: { computed: {
color() { color() {
return statusColor[this.status]; return statusColor[this.status];
}, },
statusLabel() { statusLabel() {
const statusMap = getStatusMap(); return getStatusText(this.status) || this.status;
return statusMap[this.status] ?? this.status;
}, },
}, },
components: { Badge }, components: { Badge },

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

@ -4,7 +4,7 @@
<template #header v-if="doc"> <template #header v-if="doc">
<StatusBadge :status="status" /> <StatusBadge :status="status" />
<DropdownWithActions <DropdownWithActions
v-for="group of groupedActions()" v-for="group of groupedActions"
:key="group.label" :key="group.label"
:type="group.type" :type="group.type"
:actions="group.actions" :actions="group.actions"
@ -163,8 +163,7 @@ import { fyo } from 'src/initFyo';
import { docsPathMap } from 'src/utils/misc'; import { docsPathMap } from 'src/utils/misc';
import { import {
docsPath, docsPath,
getActionsForDocument, getGroupedActionsForDoc,
getGroupedActionsForDocument,
routeTo, routeTo,
showMessageDialog, showMessageDialog,
} from 'src/utils/ui'; } from 'src/utils/ui';
@ -209,6 +208,9 @@ export default {
this.chstatus; this.chstatus;
return getDocStatus(this.doc); return getDocStatus(this.doc);
}, },
groupedActions() {
return getGroupedActionsForDoc(this.doc);
},
}, },
activated() { activated() {
docsPath.value = docsPathMap[this.schemaName]; docsPath.value = docsPathMap[this.schemaName];
@ -248,9 +250,6 @@ export default {
this.quickEditDoc = doc; this.quickEditDoc = doc;
this.quickEditFields = fields; this.quickEditFields = fields;
}, },
groupedActions() {
return getGroupedActionsForDocument(this.doc);
},
getField(fieldname) { getField(fieldname) {
return fyo.getField(this.schemaName, fieldname); return fyo.getField(this.schemaName, fieldname);
}, },

View File

@ -28,7 +28,7 @@
<feather-icon name="settings" class="w-4 h-4" /> <feather-icon name="settings" class="w-4 h-4" />
</Button> </Button>
<DropdownWithActions <DropdownWithActions
v-for="group of groupedActions()" v-for="group of groupedActions"
:key="group.label" :key="group.label"
:type="group.type" :type="group.type"
:actions="group.actions" :actions="group.actions"
@ -267,8 +267,9 @@
</div> </div>
</template> </template>
<template #quickedit v-if="quickEditDoc"> <template #quickedit v-if="quickEditDoc || linked">
<QuickEditForm <QuickEditForm
v-if="quickEditDoc && !linked"
class="w-quick-edit" class="w-quick-edit"
:name="quickEditDoc.name" :name="quickEditDoc.name"
:show-name="false" :show-name="false"
@ -281,6 +282,12 @@
:load-on-close="false" :load-on-close="false"
@close="toggleQuickEditDoc(null)" @close="toggleQuickEditDoc(null)"
/> />
<LinkedEntryWidget
v-if="linked && !quickEditDoc"
:linked="linked"
@close-widget="linked = null"
/>
</template> </template>
</FormContainer> </FormContainer>
</template> </template>
@ -296,11 +303,13 @@ import DropdownWithActions from 'src/components/DropdownWithActions.vue';
import FormContainer from 'src/components/FormContainer.vue'; import FormContainer from 'src/components/FormContainer.vue';
import FormHeader from 'src/components/FormHeader.vue'; import FormHeader from 'src/components/FormHeader.vue';
import StatusBadge from 'src/components/StatusBadge.vue'; import StatusBadge from 'src/components/StatusBadge.vue';
import LinkedEntryWidget from 'src/components/Widgets/LinkedEntryWidget.vue';
import { fyo } from 'src/initFyo'; import { fyo } from 'src/initFyo';
import { docsPathMap } from 'src/utils/misc'; import { docsPathMap } from 'src/utils/misc';
import { import {
docsPath, docsPath,
getGroupedActionsForDocument, getGroupedActionsForDoc,
getStatus,
routeTo, routeTo,
showMessageDialog, showMessageDialog,
} from 'src/utils/ui'; } from 'src/utils/ui';
@ -321,6 +330,7 @@ export default {
QuickEditForm, QuickEditForm,
ExchangeRate, ExchangeRate,
FormHeader, FormHeader,
LinkedEntryWidget,
}, },
provide() { provide() {
return { return {
@ -338,12 +348,42 @@ export default {
color: null, color: null,
printSettings: null, printSettings: null,
companyName: null, companyName: null,
linked: null,
}; };
}, },
updated() { updated() {
this.chstatus = !this.chstatus; this.chstatus = !this.chstatus;
}, },
computed: { computed: {
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() { address() {
return this.printSettings && this.printSettings.getLink('address'); return this.printSettings && this.printSettings.getLink('address');
}, },
@ -416,6 +456,26 @@ export default {
}, },
methods: { methods: {
routeTo, 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() { toggleInvoiceSettings() {
if (!this.schemaName) { if (!this.schemaName) {
return; return;
@ -446,9 +506,6 @@ export default {
this.quickEditFields = fields; this.quickEditFields = fields;
}, },
groupedActions() {
return getGroupedActionsForDocument(this.doc);
},
getField(fieldname) { getField(fieldname) {
return fyo.getField(this.schemaName, fieldname); return fyo.getField(this.schemaName, fieldname);
}, },

View File

@ -4,7 +4,7 @@
<template #header v-if="doc"> <template #header v-if="doc">
<StatusBadge :status="status" /> <StatusBadge :status="status" />
<DropdownWithActions <DropdownWithActions
v-for="group of groupedActions()" v-for="group of groupedActions"
:key="group.label" :key="group.label"
:type="group.type" :type="group.type"
:actions="group.actions" :actions="group.actions"
@ -150,8 +150,7 @@ import { fyo } from 'src/initFyo';
import { docsPathMap } from 'src/utils/misc'; import { docsPathMap } from 'src/utils/misc';
import { import {
docsPath, docsPath,
getActionsForDocument, getGroupedActionsForDoc,
getGroupedActionsForDocument,
routeTo, routeTo,
showMessageDialog, showMessageDialog,
} from 'src/utils/ui'; } from 'src/utils/ui';
@ -222,14 +221,14 @@ export default {
} }
return fyo.format(value, 'Currency'); return fyo.format(value, 'Currency');
}, },
groupedActions() {
return getGroupedActionsForDoc(this.doc);
},
}, },
methods: { methods: {
getField(fieldname) { getField(fieldname) {
return fyo.getField(ModelNameEnum.JournalEntry, fieldname); return fyo.getField(ModelNameEnum.JournalEntry, fieldname);
}, },
groupedActions() {
return getGroupedActionsForDocument(this.doc);
},
async sync() { async sync() {
try { try {
await this.doc.sync(); await this.doc.sync();

View File

@ -110,7 +110,7 @@ import StatusBadge from 'src/components/StatusBadge.vue';
import TwoColumnForm from 'src/components/TwoColumnForm.vue'; import TwoColumnForm from 'src/components/TwoColumnForm.vue';
import { fyo } from 'src/initFyo'; import { fyo } from 'src/initFyo';
import { getQuickEditWidget } from 'src/utils/quickEditWidgets'; import { getQuickEditWidget } from 'src/utils/quickEditWidgets';
import { getActionsForDocument, openQuickEdit } from 'src/utils/ui'; import { getActionsForDoc, openQuickEdit } from 'src/utils/ui';
export default { export default {
name: 'QuickEditForm', name: 'QuickEditForm',
@ -198,7 +198,7 @@ export default {
return fieldnames.map((f) => fyo.getField(this.schemaName, f)); return fieldnames.map((f) => fyo.getField(this.schemaName, f));
}, },
actions() { actions() {
return getActionsForDocument(this.doc); return getActionsForDoc(this.doc);
}, },
quickEditWidget() { quickEditWidget() {
if (this.doc?.notInserted ?? true) { if (this.doc?.notInserted ?? true) {

View File

@ -103,7 +103,7 @@ export default defineComponent({
acc[ac.group] ??= { acc[ac.group] ??= {
group: ac.group, group: ac.group,
label: ac.label ?? '', label: ac.label ?? '',
type: ac.type, e: ac.type,
actions: [], actions: [],
}; };

View File

@ -156,6 +156,8 @@ export function getEntryRoute(schemaName: string, name: string) {
ModelNameEnum.SalesInvoice, ModelNameEnum.SalesInvoice,
ModelNameEnum.PurchaseInvoice, ModelNameEnum.PurchaseInvoice,
ModelNameEnum.JournalEntry, ModelNameEnum.JournalEntry,
ModelNameEnum.Shipment,
ModelNameEnum.PurchaseReceipt,
].includes(schemaName as ModelNameEnum) ].includes(schemaName as ModelNameEnum)
) { ) {
return `/edit/${schemaName}/${name}`; return `/edit/${schemaName}/${name}`;

View File

@ -257,7 +257,7 @@ export async function cancelDocWithPrompt(doc: Doc) {
}); });
} }
export function getActionsForDocument(doc?: Doc): Action[] { export function getActionsForDoc(doc?: Doc): Action[] {
if (!doc) return []; if (!doc) return [];
const actions: Action[] = [ const actions: Action[] = [
@ -279,7 +279,7 @@ export function getActionsForDocument(doc?: Doc): Action[] {
}); });
} }
export function getGroupedActionsForDocument(doc?: Doc) { export function getGroupedActionsForDoc(doc?: Doc) {
type Group = { type Group = {
group: string; group: string;
label: string; label: string;
@ -287,7 +287,7 @@ export function getGroupedActionsForDocument(doc?: Doc) {
actions: Action[]; actions: Action[];
}; };
const actions = getActionsForDocument(doc); const actions = getActionsForDoc(doc);
const actionsMap = actions.reduce((acc, ac) => { const actionsMap = actions.reduce((acc, ac) => {
if (!ac.group) { if (!ac.group) {
ac.group = ''; ac.group = '';
@ -309,7 +309,7 @@ export function getGroupedActionsForDocument(doc?: Doc) {
.sort() .sort()
.map((k) => actionsMap[k]); .map((k) => actionsMap[k]);
return [grouped, actionsMap['']].flat(); return [grouped, actionsMap['']].flat().filter(Boolean);
} }
function getCancelAction(doc: Doc): Action { function getCancelAction(doc: Doc): Action {
@ -397,3 +397,15 @@ function getDuplicateAction(doc: Doc): Action {
}, },
}; };
} }
export function getStatus(entry: { cancelled?: boolean; submitted?: boolean }) {
if (entry.cancelled) {
return 'Cancelled';
}
if (entry.submitted) {
return 'Submitted';
}
return 'Saved';
}

View File

@ -186,3 +186,34 @@ export function safeParseFloat(value: unknown): number {
export function safeParseInt(value: unknown): number { export function safeParseInt(value: unknown): number {
return safeParseNumber(value, parseInt); return safeParseNumber(value, parseInt);
} }
export function joinMapLists<A, B>(
listA: A[],
listB: B[],
keyA: keyof A,
keyB: keyof B
): (A & B)[] {
const mapA = getMapFromList(listA, keyA);
const mapB = getMapFromList(listB, keyB);
const keyListA = listA
.map((i) => i[keyA])
.filter((k) => (k as unknown as string) in mapB);
const keyListB = listB
.map((i) => i[keyB])
.filter((k) => (k as unknown as string) in mapA);
const keys = new Set([keyListA, keyListB].flat().sort());
const joint: (A & B)[] = [];
for (const k of keys) {
const a = mapA[k as unknown as string];
const b = mapB[k as unknown as string];
const c = { ...a, ...b };
joint.push(c);
}
return joint;
}