2
0
mirror of https://github.com/frappe/books.git synced 2024-12-22 10:58:59 +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,
FiltersMap,
FormulaMap,
HiddenMap,
HiddenMap
} from 'fyo/model/types';
import { DEFAULT_CURRENCY } from 'fyo/utils/consts';
import { ValidationError } from 'fyo/utils/errors';
import {
getExchangeRate,
getInvoiceActions,
getNumberSeries,
getNumberSeries
} from 'models/helpers';
import { InventorySettings } from 'models/inventory/InventorySettings';
import { StockTransfer } from 'models/inventory/StockTransfer';
@ -22,7 +22,10 @@ import { Transactional } from 'models/Transactional/Transactional';
import { ModelNameEnum } from 'models/types';
import { Money } from 'pesa';
import { FieldTypeEnum, Schema } from 'schemas/types';
import { getIsNullOrUndef, safeParseFloat } from 'utils';
import {
getIsNullOrUndef, joinMapLists,
safeParseFloat
} from 'utils';
import { Defaults } from '../Defaults/Defaults';
import { InvoiceItem } from '../InvoiceItem/InvoiceItem';
import { Item } from '../Item/Item';
@ -79,6 +82,22 @@ export abstract class Invoice extends Transactional {
: ModelNameEnum.PurchaseReceipt;
}
get hasLinkedTransfers() {
if (!this.submitted) {
return false;
}
return this.getStockTransferred() > 0;
}
get hasLinkedPayments() {
if (!this.submitted) {
return false;
}
return !this.baseGrandTotal?.eq(this.outstandingAmount!);
}
constructor(schema: Schema, data: DocValueMap, fyo: Fyo) {
super(schema, data, fyo);
this._setGetCurrencies();
@ -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() {
return (this.items ?? []).reduce(
(acc, item) => (item.stockNotTransferred ?? 0) + acc,
@ -597,4 +624,78 @@ export abstract class Invoice extends Transactional {
})) as { name: string }[];
return transfers;
}
async getLinkedPayments() {
if (!this.hasLinkedPayments) {
return [];
}
const paymentFors = (await this.fyo.db.getAllRaw('PaymentFor', {
fields: ['parent', 'amount'],
filters: { referenceName: this.name!, referenceType: this.schemaName },
})) as { parent: string; amount: string }[];
const payments = (await this.fyo.db.getAllRaw('Payment', {
fields: ['name', 'date', 'submitted', 'cancelled'],
filters: { name: ['in', paymentFors.map((p) => p.parent)] },
})) as {
name: string;
date: string;
submitted: number;
cancelled: number;
}[];
return joinMapLists(payments, paymentFors, 'name', 'parent')
.map((j) => ({
name: j.name,
date: new Date(j.date),
submitted: !!j.submitted,
cancelled: !!j.cancelled,
amount: this.fyo.pesa(j.amount),
}))
.sort((a, b) => a.date.valueOf() - b.date.valueOf());
}
async getLinkedStockTransfers() {
if (!this.hasLinkedTransfers) {
return [];
}
const schemaName = this.stockTransferSchemaName;
const transfers = (await this.fyo.db.getAllRaw(schemaName, {
fields: ['name', 'date', 'submitted', 'cancelled'],
filters: { backReference: this.name! },
})) as {
name: string;
date: string;
submitted: number;
cancelled: number;
}[];
const itemSchemaName = schemaName + 'Item';
const transferItems = (await this.fyo.db.getAllRaw(itemSchemaName, {
fields: ['parent', 'quantity', 'location', 'amount'],
filters: {
parent: ['in', transfers.map((t) => t.name)],
item: ['in', this.items!.map((i) => i.item!)],
},
})) as {
parent: string;
quantity: number;
location: string;
amount: string;
}[];
return joinMapLists(transfers, transferItems, 'name', 'parent')
.map((j) => ({
name: j.name,
date: new Date(j.date),
submitted: !!j.submitted,
cancelled: !!j.cancelled,
amount: this.fyo.pesa(j.amount),
location: j.location,
quantity: j.quantity,
}))
.sort((a, b) => a.date.valueOf() - b.date.valueOf());
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@
<template #header v-if="doc">
<StatusBadge :status="status" />
<DropdownWithActions
v-for="group of groupedActions()"
v-for="group of groupedActions"
:key="group.label"
:type="group.type"
:actions="group.actions"
@ -150,8 +150,7 @@ import { fyo } from 'src/initFyo';
import { docsPathMap } from 'src/utils/misc';
import {
docsPath,
getActionsForDocument,
getGroupedActionsForDocument,
getGroupedActionsForDoc,
routeTo,
showMessageDialog,
} from 'src/utils/ui';
@ -222,14 +221,14 @@ export default {
}
return fyo.format(value, 'Currency');
},
groupedActions() {
return getGroupedActionsForDoc(this.doc);
},
},
methods: {
getField(fieldname) {
return fyo.getField(ModelNameEnum.JournalEntry, fieldname);
},
groupedActions() {
return getGroupedActionsForDocument(this.doc);
},
async sync() {
try {
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 { fyo } from 'src/initFyo';
import { getQuickEditWidget } from 'src/utils/quickEditWidgets';
import { getActionsForDocument, openQuickEdit } from 'src/utils/ui';
import { getActionsForDoc, openQuickEdit } from 'src/utils/ui';
export default {
name: 'QuickEditForm',
@ -198,7 +198,7 @@ export default {
return fieldnames.map((f) => fyo.getField(this.schemaName, f));
},
actions() {
return getActionsForDocument(this.doc);
return getActionsForDoc(this.doc);
},
quickEditWidget() {
if (this.doc?.notInserted ?? true) {

View File

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

View File

@ -156,6 +156,8 @@ export function getEntryRoute(schemaName: string, name: string) {
ModelNameEnum.SalesInvoice,
ModelNameEnum.PurchaseInvoice,
ModelNameEnum.JournalEntry,
ModelNameEnum.Shipment,
ModelNameEnum.PurchaseReceipt,
].includes(schemaName as ModelNameEnum)
) {
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 [];
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 = {
group: string;
label: string;
@ -287,7 +287,7 @@ export function getGroupedActionsForDocument(doc?: Doc) {
actions: Action[];
};
const actions = getActionsForDocument(doc);
const actions = getActionsForDoc(doc);
const actionsMap = actions.reduce((acc, ac) => {
if (!ac.group) {
ac.group = '';
@ -309,7 +309,7 @@ export function getGroupedActionsForDocument(doc?: Doc) {
.sort()
.map((k) => actionsMap[k]);
return [grouped, actionsMap['']].flat();
return [grouped, actionsMap['']].flat().filter(Boolean);
}
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 {
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;
}