fix: group matches to get possible and perfect matches

This commit is contained in:
Ahmed Bouhuolia
2024-06-29 13:21:59 +02:00
parent cb1f587637
commit b01528c06b
8 changed files with 132 additions and 29 deletions

View File

@@ -36,8 +36,6 @@ export class BankTransactionsMatchingController extends BaseController {
this.validationResult, this.validationResult,
this.unmatchMatchedBankTransaction.bind(this) this.unmatchMatchedBankTransaction.bind(this)
); );
router.get('/', this.getMatchedTransactions.bind(this));
return router; return router;
} }

View File

@@ -6,18 +6,27 @@ import { ServiceError } from '@/exceptions';
import CheckPolicies from '@/api/middleware/CheckPolicies'; import CheckPolicies from '@/api/middleware/CheckPolicies';
import { AbilitySubject, CashflowAction } from '@/interfaces'; import { AbilitySubject, CashflowAction } from '@/interfaces';
import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; import { CashflowApplication } from '@/services/Cashflow/CashflowApplication';
import { GetMatchedTransactionsFilter } from '@/services/Banking/Matching/types';
import { MatchBankTransactionsApplication } from '@/services/Banking/Matching/MatchBankTransactionsApplication';
@Service() @Service()
export default class GetCashflowAccounts extends BaseController { export default class GetCashflowAccounts extends BaseController {
@Inject() @Inject()
private cashflowApplication: CashflowApplication; private cashflowApplication: CashflowApplication;
@Inject()
private bankTransactionsMatchingApp: MatchBankTransactionsApplication;
/** /**
* Controller router. * Controller router.
*/ */
public router() { public router() {
const router = Router(); const router = Router();
router.get(
'/transactions/:transactionId/matches',
this.getMatchedTransactions.bind(this)
);
router.get( router.get(
'/transactions/:transactionId', '/transactions/:transactionId',
CheckPolicies(CashflowAction.View, AbilitySubject.Cashflow), CheckPolicies(CashflowAction.View, AbilitySubject.Cashflow),
@@ -47,7 +56,6 @@ export default class GetCashflowAccounts extends BaseController {
tenantId, tenantId,
transactionId transactionId
); );
return res.status(200).send({ return res.status(200).send({
cashflow_transaction: this.transfromToResponse(cashflowTransaction), cashflow_transaction: this.transfromToResponse(cashflowTransaction),
}); });
@@ -56,6 +64,34 @@ export default class GetCashflowAccounts extends BaseController {
} }
}; };
/**
* Retrieves the matched transactions.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
*/
private async getMatchedTransactions(
req: Request<{ transactionId: number }>,
res: Response,
next: NextFunction
) {
const { tenantId } = req;
const { transactionId } = req.params;
const filter = this.matchedQueryData(req) as GetMatchedTransactionsFilter;
try {
const data =
await this.bankTransactionsMatchingApp.getMatchedTransactions(
tenantId,
transactionId,
filter
);
return res.status(200).send(data);
} catch (error) {
next(error);
}
}
/** /**
* Catches the service errors. * Catches the service errors.
* @param {Error} error - Error. * @param {Error} error - Error.

View File

@@ -1,13 +1,18 @@
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import * as R from 'ramda'; import * as R from 'ramda';
import moment from 'moment';
import { PromisePool } from '@supercharge/promise-pool'; import { PromisePool } from '@supercharge/promise-pool';
import { GetMatchedTransactionsFilter, MatchedTransactionsPOJO } from './types'; import { GetMatchedTransactionsFilter, MatchedTransactionsPOJO } from './types';
import { GetMatchedTransactionsByExpenses } from './GetMatchedTransactionsByExpenses'; import { GetMatchedTransactionsByExpenses } from './GetMatchedTransactionsByExpenses';
import { GetMatchedTransactionsByBills } from './GetMatchedTransactionsByBills'; import { GetMatchedTransactionsByBills } from './GetMatchedTransactionsByBills';
import { GetMatchedTransactionsByManualJournals } from './GetMatchedTransactionsByManualJournals'; import { GetMatchedTransactionsByManualJournals } from './GetMatchedTransactionsByManualJournals';
import HasTenancyService from '@/services/Tenancy/TenancyService';
@Service() @Service()
export class GetMatchedTransactions { export class GetMatchedTransactions {
@Inject()
private tenancy: HasTenancyService;
@Inject() @Inject()
private getMatchedInvoicesService: GetMatchedTransactionsByExpenses; private getMatchedInvoicesService: GetMatchedTransactionsByExpenses;
@@ -36,11 +41,20 @@ export class GetMatchedTransactions {
* Retrieves the matched transactions. * Retrieves the matched transactions.
* @param {number} tenantId - * @param {number} tenantId -
* @param {GetMatchedTransactionsFilter} filter - * @param {GetMatchedTransactionsFilter} filter -
* @returns {Promise<MatchedTransactionsPOJO>}
*/ */
public async getMatchedTransactions( public async getMatchedTransactions(
tenantId: number, tenantId: number,
uncategorizedTransactionId: number,
filter: GetMatchedTransactionsFilter filter: GetMatchedTransactionsFilter
): Promise<MatchedTransactionsPOJO> { ): Promise<MatchedTransactionsPOJO> {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const uncategorizedTransaction =
await UncategorizedCashflowTransaction.query()
.findById(uncategorizedTransactionId)
.throwIfNotFound();
const filtered = filter.transactionType const filtered = filter.transactionType
? this.registered.filter((item) => item.type === filter.transactionType) ? this.registered.filter((item) => item.type === filter.transactionType)
: this.registered; : this.registered;
@@ -50,6 +64,39 @@ export class GetMatchedTransactions {
.process(async ({ type, service }) => { .process(async ({ type, service }) => {
return service.getMatchedTransactions(tenantId, filter); return service.getMatchedTransactions(tenantId, filter);
}); });
return R.compose(R.flatten)(matchedTransactions?.results);
const { perfectMatches, possibleMatches } = this.groupMatchedResults(
uncategorizedTransaction,
matchedTransactions
);
return {
perfectMatches,
possibleMatches,
};
}
/**
* Groups the given results for getting perfect and possible matches
* based on the given uncategorized transaction.
* @param uncategorizedTransaction
* @param matchedTransactions
* @returns {MatchedTransactionsPOJO}
*/
private groupMatchedResults(
uncategorizedTransaction,
matchedTransactions
): MatchedTransactionsPOJO {
const results = R.compose(R.flatten)(matchedTransactions?.results);
const perfectMatches = R.filter(
(match) =>
match.amount === uncategorizedTransaction.amount &&
moment(match.date).isSame(uncategorizedTransaction.date, 'day'),
results
);
const possibleMatches = R.difference(results, perfectMatches);
return { perfectMatches, possibleMatches };
} }
} }

View File

@@ -23,10 +23,12 @@ export class MatchBankTransactionsApplication {
*/ */
public getMatchedTransactions( public getMatchedTransactions(
tenantId: number, tenantId: number,
uncategorizedTransactionId: number,
filter: GetMatchedTransactionsFilter filter: GetMatchedTransactionsFilter
) { ) {
return this.getMatchedTransactionsService.getMatchedTransactions( return this.getMatchedTransactionsService.getMatchedTransactions(
tenantId, tenantId,
uncategorizedTransactionId,
filter filter
); );
} }

View File

@@ -49,7 +49,10 @@ export interface MatchedTransactionPOJO {
transactionId: number; transactionId: number;
} }
export type MatchedTransactionsPOJO = Array<MatchedTransactionPOJO[]>; export type MatchedTransactionsPOJO = {
perfectMatches: Array<MatchedTransactionPOJO[]>;
possibleMatches: Array<MatchedTransactionPOJO[]>;
};
export const ERRORS = { export const ERRORS = {
RESOURCE_TYPE_MATCHING_TRANSACTION_INVALID: RESOURCE_TYPE_MATCHING_TRANSACTION_INVALID:
@@ -59,5 +62,5 @@ export const ERRORS = {
TOTAL_MATCHING_TRANSACTIONS_INVALID: 'TOTAL_MATCHING_TRANSACTIONS_INVALID', TOTAL_MATCHING_TRANSACTIONS_INVALID: 'TOTAL_MATCHING_TRANSACTIONS_INVALID',
TRANSACTION_ALREADY_MATCHED: 'TRANSACTION_ALREADY_MATCHED', TRANSACTION_ALREADY_MATCHED: 'TRANSACTION_ALREADY_MATCHED',
CANNOT_MATCH_EXCLUDED_TRANSACTION: 'CANNOT_MATCH_EXCLUDED_TRANSACTION', CANNOT_MATCH_EXCLUDED_TRANSACTION: 'CANNOT_MATCH_EXCLUDED_TRANSACTION',
CANNOT_DELETE_TRANSACTION_MATCHED: 'CANNOT_DELETE_TRANSACTION_MATCHED' CANNOT_DELETE_TRANSACTION_MATCHED: 'CANNOT_DELETE_TRANSACTION_MATCHED',
}; };

View File

@@ -23,7 +23,7 @@ const initialValues = {
}; };
export function MatchingBankTransaction() { export function MatchingBankTransaction() {
const uncategorizedTransactionId = 1; const uncategorizedTransactionId = 4;
const { mutateAsync: matchTransaction } = useMatchTransaction(); const { mutateAsync: matchTransaction } = useMatchTransaction();
// Handles the form submitting. // Handles the form submitting.
@@ -37,7 +37,7 @@ export function MatchingBankTransaction() {
}); });
return; return;
} }
matchTransaction([uncategorizedTransactionId, _values]) matchTransaction({ id: uncategorizedTransactionId, values: _values })
.then(() => { .then(() => {
AppToaster.show({ AppToaster.show({
intent: Intent.SUCCESS, intent: Intent.SUCCESS,
@@ -53,7 +53,9 @@ export function MatchingBankTransaction() {
}; };
return ( return (
<MatchingTransactionBoot> <MatchingTransactionBoot
uncategorizedTransactionId={uncategorizedTransactionId}
>
<Formik initialValues={initialValues} onSubmit={handleSubmit}> <Formik initialValues={initialValues} onSubmit={handleSubmit}>
<Form> <Form>
<MatchingBankTransactionContent /> <MatchingBankTransactionContent />
@@ -78,10 +80,10 @@ function MatchingBankTransactionContent() {
* @returns {React.ReactNode} * @returns {React.ReactNode}
*/ */
function PerfectMatchingTransactions() { function PerfectMatchingTransactions() {
const { matchingTransactions } = useMatchingTransactionBoot(); const { perfectMatches, perfectMatchesCount } = useMatchingTransactionBoot();
// Can't continue if the perfect matches is empty. // Can't continue if the perfect matches is empty.
if (isEmpty(matchingTransactions)) { if (isEmpty(perfectMatches)) {
return null; return null;
} }
return ( return (
@@ -90,13 +92,13 @@ function PerfectMatchingTransactions() {
<Group spacing={6}> <Group spacing={6}>
<h2 className={styles.matchBarTitle}>Perfect Matchines</h2> <h2 className={styles.matchBarTitle}>Perfect Matchines</h2>
<Tag minimal round intent={Intent.SUCCESS}> <Tag minimal round intent={Intent.SUCCESS}>
2 {perfectMatchesCount}
</Tag> </Tag>
</Group> </Group>
</Box> </Box>
<Stack spacing={9} style={{ padding: '12px 15px' }}> <Stack spacing={9} style={{ padding: '12px 15px' }}>
{matchingTransactions.map((match, index) => ( {perfectMatches.map((match, index) => (
<MatchTransactionField <MatchTransactionField
key={index} key={index}
label={`${match.transsactionTypeFormatted} for ${match.amountFormatted}`} label={`${match.transsactionTypeFormatted} for ${match.amountFormatted}`}
@@ -115,10 +117,10 @@ function PerfectMatchingTransactions() {
* @returns {React.ReactNode} * @returns {React.ReactNode}
*/ */
function GoodMatchingTransactions() { function GoodMatchingTransactions() {
const { matchingTransactions } = useMatchingTransactionBoot(); const { possibleMatches } = useMatchingTransactionBoot();
// Can't continue if the possible matches is emoty. // Can't continue if the possible matches is emoty.
if (isEmpty(matchingTransactions)) { if (isEmpty(possibleMatches)) {
return null; return null;
} }
return ( return (
@@ -133,7 +135,7 @@ function GoodMatchingTransactions() {
</Box> </Box>
<Stack spacing={9} style={{ padding: '12px 15px' }}> <Stack spacing={9} style={{ padding: '12px 15px' }}>
{matchingTransactions.map((match, index) => ( {possibleMatches.map((match, index) => (
<MatchTransaction <MatchTransaction
key={index} key={index}
label={`${match.transsactionTypeFormatted} for ${match.amountFormatted}`} label={`${match.transsactionTypeFormatted} for ${match.amountFormatted}`}

View File

@@ -3,7 +3,7 @@ import React, { createContext } from 'react';
interface MatchingTransactionBootValues { interface MatchingTransactionBootValues {
isMatchingTransactionsLoading: boolean; isMatchingTransactionsLoading: boolean;
matchingTransactions: Array<any>; possibleMatches: Array<any>;
perfectMatchesCount: number; perfectMatchesCount: number;
perfectMatches: Array<any>; perfectMatches: Array<any>;
matches: Array<any>; matches: Array<any>;
@@ -14,21 +14,24 @@ const RuleFormBootContext = createContext<MatchingTransactionBootValues>(
); );
interface RuleFormBootProps { interface RuleFormBootProps {
uncategorizedTransactionId: number;
children: React.ReactNode; children: React.ReactNode;
} }
function MatchingTransactionBoot({ ...props }: RuleFormBootProps) { function MatchingTransactionBoot({
uncategorizedTransactionId,
...props
}: RuleFormBootProps) {
const { const {
data: matchingTransactions, data: matchingTransactions,
isLoading: isMatchingTransactionsLoading, isLoading: isMatchingTransactionsLoading,
} = useMatchingTransactions(); } = useMatchingTransactions(uncategorizedTransactionId);
const provider = { const provider = {
isMatchingTransactionsLoading, isMatchingTransactionsLoading,
matchingTransactions, possibleMatches: matchingTransactions?.possibleMatches,
perfectMatchesCount: 2, perfectMatchesCount: 2,
perfectMatches: [], perfectMatches: matchingTransactions?.perfectMatches,
matches: [],
} as MatchingTransactionBootValues; } as MatchingTransactionBootValues;
return <RuleFormBootContext.Provider value={provider} {...props} />; return <RuleFormBootContext.Provider value={provider} {...props} />;

View File

@@ -1,5 +1,7 @@
// @ts-nocheck // @ts-nocheck
import { import {
UseMutateFunction,
UseMutationResult,
useInfiniteQuery, useInfiniteQuery,
useMutation, useMutation,
useQuery, useQuery,
@@ -86,15 +88,18 @@ export function useBankRule(bankRuleId: number, props) {
* *
* @returns * @returns
*/ */
export function useMatchingTransactions(props?: any) { export function useMatchingTransactions(
uncategorizedTransactionId: number,
props?: any,
) {
const apiRequest = useApiRequest(); const apiRequest = useApiRequest();
return useQuery( return useQuery<any>(
['MATCHING_TRANSACTION'], ['MATCHING_TRANSACTION', uncategorizedTransactionId],
() => () =>
apiRequest apiRequest
.get(`/banking/matches`) .get(`/cashflow/transactions/${uncategorizedTransactionId}/matches`)
.then((res) => transformToCamelCase(res.data.data)), .then((res) => transformToCamelCase(res.data)),
props, props,
); );
} }
@@ -135,11 +140,18 @@ export function useUnexcludeUncategorizedTransaction(props) {
); );
} }
export function useMatchTransaction(props?: any) { interface MatchUncategorizedTransactionValues {
id: number;
value: any;
}
export function useMatchTransaction(
props?: any,
): UseMutationResult<MatchUncategorizedTransactionValues> {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const apiRequest = useApiRequest(); const apiRequest = useApiRequest();
return useMutation( return useMutation<MatchUncategorizedTransactionValues>(
([uncategorizedTransactionId, values]) => ([uncategorizedTransactionId, values]) =>
apiRequest.post(`/banking/matches/${uncategorizedTransactionId}`, values), apiRequest.post(`/banking/matches/${uncategorizedTransactionId}`, values),
{ {