Merge pull request '3052-feat(client_defaulter): section and tests' (#858) from 3052-client_defaulter into dev
gitea/salix/pipeline/head There was a failure building this commit Details

Reviewed-on: #858
Reviewed-by: Carlos Jimenez Ruiz <carlosjr@verdnatura.es>
This commit is contained in:
Carlos Jimenez Ruiz 2022-02-01 09:12:04 +00:00
commit 9fac23038f
14 changed files with 623 additions and 2 deletions

View File

@ -2435,3 +2435,11 @@ CALL `cache`.`last_buy_refresh`(FALSE);
UPDATE `vn`.`item` SET `genericFk` = 9
WHERE `id` = 2;
INSERT INTO `bs`.`defaulter` (`clientFk`, `amount`, `created`, `defaulterSinced`)
VALUES
(1101, 500, CURDATE(), CURDATE()),
(1102, 500, CURDATE(), CURDATE()),
(1103, 500, CURDATE(), CURDATE()),
(1107, 500, CURDATE(), CURDATE()),
(1109, 500, CURDATE(), CURDATE());

View File

@ -304,6 +304,16 @@ export default {
saveNewInsuranceCredit: 'vn-client-credit-insurance-insurance-create button[type="submit"]',
anyCreditInsuranceLine: 'vn-client-credit-insurance-insurance-index vn-tbody > vn-tr',
},
clientDefaulter: {
anyClient: 'vn-client-defaulter-index vn-tbody > vn-tr',
firstClientName: 'vn-client-defaulter-index vn-tbody > vn-tr:nth-child(1) > vn-td:nth-child(2) > span',
firstSalesPersonName: 'vn-client-defaulter-index vn-tbody > vn-tr:nth-child(1) > vn-td:nth-child(3) > span',
firstObservation: 'vn-client-defaulter-index vn-tbody > vn-tr:nth-child(1) > vn-td:nth-child(6) > vn-textarea[ng-model="defaulter.observation"]',
allDefaulterCheckbox: 'vn-client-defaulter-index vn-thead vn-multi-check',
addObservationButton: 'vn-client-defaulter-index vn-button[icon="icon-notes"]',
observation: '.vn-dialog.shown vn-textarea[ng-model="$ctrl.defaulter.observation"]',
saveButton: 'button[response="accept"]'
},
clientContacts: {
addContactButton: 'vn-client-contact vn-icon[icon="add_circle"]',
name: 'vn-client-contact vn-textfield[ng-model="contact.name"]',

View File

@ -0,0 +1,73 @@
import selectors from '../../helpers/selectors.js';
import getBrowser from '../../helpers/puppeteer';
describe('Client defaulter path', () => {
let browser;
let page;
beforeAll(async() => {
browser = await getBrowser();
page = browser.page;
await page.loginAndModule('insurance', 'client');
await page.accessToSection('client.defaulter.index');
});
afterAll(async() => {
await browser.close();
});
it('should count the amount of clients in the turns section', async() => {
const result = await page.countElement(selectors.clientDefaulter.anyClient);
expect(result).toEqual(5);
});
it('should check contain expected client', async() => {
const clientName =
await page.waitToGetProperty(selectors.clientDefaulter.firstClientName, 'innerText');
const salesPersonName =
await page.waitToGetProperty(selectors.clientDefaulter.firstSalesPersonName, 'innerText');
expect(clientName).toEqual('Ororo Munroe');
expect(salesPersonName).toEqual('salesPerson');
});
it('should first observation not changed', async() => {
const expectedObservation = 'Madness, as you know, is like gravity, all it takes is a little push';
const result = await page.waitToGetProperty(selectors.clientDefaulter.firstObservation, 'value');
expect(result).toContain(expectedObservation);
});
it('should not add empty observation', async() => {
await page.waitToClick(selectors.clientDefaulter.allDefaulterCheckbox);
await page.waitToClick(selectors.clientDefaulter.addObservationButton);
await page.write(selectors.clientDefaulter.observation, '');
await page.waitToClick(selectors.clientDefaulter.saveButton);
const message = await page.waitForSnackbar();
expect(message.text).toContain(`The message can't be empty`);
});
it('shoul checked all defaulters', async() => {
await page.loginAndModule('insurance', 'client');
await page.accessToSection('client.defaulter.index');
await page.waitToClick(selectors.clientDefaulter.allDefaulterCheckbox);
});
it('should add observation for all clients', async() => {
await page.waitToClick(selectors.clientDefaulter.addObservationButton);
await page.write(selectors.clientDefaulter.observation, 'My new observation');
await page.waitToClick(selectors.clientDefaulter.saveButton);
});
it('should first observation changed', async() => {
const message = await page.waitForSnackbar();
const result = await page.waitToGetProperty(selectors.clientDefaulter.firstObservation, 'value');
expect(message.text).toContain('Observation saved!');
expect(result).toContain('My new observation');
});
});

View File

@ -0,0 +1,90 @@
const ParameterizedSQL = require('loopback-connector').ParameterizedSQL;
const buildFilter = require('vn-loopback/util/filter').buildFilter;
const mergeFilters = require('vn-loopback/util/filter').mergeFilters;
module.exports = Self => {
Self.remoteMethodCtx('filter', {
description: 'Find all instances of the model matched by filter from the data source.',
accessType: 'READ',
accepts: [
{
arg: 'filter',
type: 'object',
description: 'Filter defining where, order, offset, and limit - must be a JSON-encoded string',
http: {source: 'query'}
},
{
arg: 'search',
type: 'string',
description: `If it's and integer searchs by id, otherwise it searchs by name`
}
],
returns: {
type: ['object'],
root: true
},
http: {
path: `/filter`,
verb: 'GET'
}
});
Self.filter = async(ctx, filter, options) => {
const conn = Self.dataSource.connector;
const myOptions = {};
if (typeof options == 'object')
Object.assign(myOptions, options);
const where = buildFilter(ctx.args, (param, value) => {
switch (param) {
case 'search':
return {or: [
{'d.clientFk': value},
{'d.clientName': {like: `%${value}%`}}
]};
}
});
filter = mergeFilters(ctx.args.filter, {where});
const stmts = [];
const stmt = new ParameterizedSQL(
`SELECT *
FROM (
SELECT
DISTINCT c.id clientFk,
c.name clientName,
c.salesPersonFk,
u.name salesPersonName,
d.amount,
co.created,
CONCAT(DATE(co.created), ' ', co.text) observation,
uw.id workerFk,
uw.name workerName,
c.creditInsurance,
d.defaulterSinced
FROM vn.defaulter d
JOIN vn.client c ON c.id = d.clientFk
LEFT JOIN vn.clientObservation co ON co.clientFk = c.id
LEFT JOIN account.user u ON u.id = c.salesPersonFk
LEFT JOIN account.user uw ON uw.id = co.workerFk
WHERE
d.created = CURDATE()
AND d.amount > 0
ORDER BY co.created DESC) d`
);
stmt.merge(conn.makeWhere(filter.where));
stmt.merge(`GROUP BY d.clientFk`);
stmt.merge(conn.makeOrderBy(filter.order));
const itemsIndex = stmts.push(stmt) - 1;
const sql = ParameterizedSQL.join(stmts, ';');
const result = await conn.executeStmt(sql, myOptions);
return itemsIndex === 0 ? result : result[itemsIndex];
};
};

View File

@ -0,0 +1,63 @@
const models = require('vn-loopback/server/server').models;
describe('defaulter filter()', () => {
const authUserId = 9;
it('should all return the tickets matching the filter', async() => {
const tx = await models.Defaulter.beginTransaction({});
try {
const options = {transaction: tx};
const filter = {};
const ctx = {req: {accessToken: {userId: authUserId}}, args: {filter: filter}};
const result = await models.Defaulter.filter(ctx, null, options);
const firstRow = result[0];
expect(firstRow.clientFk).toEqual(1101);
expect(result.length).toEqual(5);
await tx.rollback();
} catch (e) {
await tx.rollback();
throw e;
}
});
it('should return the defaulter with id', async() => {
const tx = await models.Defaulter.beginTransaction({});
try {
const options = {transaction: tx};
const ctx = {req: {accessToken: {userId: authUserId}}, args: {search: 1101}};
const result = await models.Defaulter.filter(ctx, null, options);
const firstRow = result[0];
expect(firstRow.clientFk).toEqual(1101);
await tx.rollback();
} catch (e) {
await tx.rollback();
throw e;
}
});
it('should return the defaulter matching the client name', async() => {
const tx = await models.Defaulter.beginTransaction({});
try {
const options = {transaction: tx};
const ctx = {req: {accessToken: {userId: authUserId}}, args: {search: 'bruce'}};
const result = await models.Defaulter.filter(ctx, null, options);
const firstRow = result[0];
expect(firstRow.clientName).toEqual('Bruce Wayne');
await tx.rollback();
} catch (e) {
await tx.rollback();
throw e;
}
});
});

View File

@ -0,0 +1,3 @@
module.exports = Self => {
require('../methods/defaulter/filter')(Self);
};

View File

@ -8,6 +8,9 @@
}
},
"properties": {
"id": {
"type": "Number"
},
"created": {
"type": "Date"
},

View File

@ -0,0 +1,186 @@
<vn-crud-model
vn-id="model"
url="Defaulters/filter"
filter="::$ctrl.filter"
limit="20"
data="defaulters"
auto-load="true">
</vn-crud-model>
<vn-portal slot="topbar">
<vn-searchbar
vn-focus
placeholder="Search client"
info="Search client by id or name"
auto-state="false"
model="model">
</vn-searchbar>
</vn-portal>
<vn-data-viewer
model="model"
class="vn-w-xl">
<vn-card>
<vn-tool-bar>
<div class="vn-pa-md">
<div class="totalBox" style="text-align: center;">
<h6 translate>Total</h6>
<vn-label-value
label="Balance due"
value="{{$ctrl.balanceDueTotal}} €">
</vn-label-value>
</div>
</div>
<div class="vn-pa-md">
<vn-button
ng-show="$ctrl.checked.length > 0"
ng-click="notesDialog.show()"
name="notesDialog"
vn-tooltip="Add observation"
icon="icon-notes">
</vn-button>
</div>
</vn-tool-bar>
<vn-table model="model">
<vn-thead>
<vn-tr>
<vn-th shrink>
<vn-multi-check
model="model">
</vn-multi-check>
</vn-th>
<vn-th field="clientName">Client</vn-th>
<vn-th field="salesPersonFk">Comercial</vn-th>
<vn-th
field="amount"
vn-tooltip="Balance due"
number>
Balance D.
</vn-th>
<vn-th
vn-tooltip="Worker who made the last observation"
shrink>
Author
</vn-th>
<vn-th expand>Last observation</vn-th>
<vn-th
vn-tooltip="Credit insurance"
number>
Credit I.
</vn-th>
<vn-th shrink-datetime>From</vn-th>
</vn-tr>
</vn-thead>
<vn-tbody>
<vn-tr ng-repeat="defaulter in defaulters">
<vn-td shrink>
<vn-check
ng-model="defaulter.checked"
vn-click-stop>
</vn-check>
</vn-td>
<vn-td>
<span
vn-click-stop="clientDescriptor.show($event, defaulter.clientFk)"
title ="{{::defaulter.clientName}}"
class="link">
{{::defaulter.clientName}}
</span>
</vn-td>
<vn-td>
<span
title="{{::defaulter.salesPersonName}}"
vn-click-stop="workerDescriptor.show($event, defaulter.salesPersonFk)"
class="link" >
{{::defaulter.salesPersonName | dashIfEmpty}}
</span>
</vn-td>
<vn-td number>{{::defaulter.amount}}</vn-td>
<vn-td shrink>
<span
title="{{::defaulter.workerName}}"
vn-click-stop="workerDescriptor.show($event, defaulter.workerFk)"
class="link" >
{{::defaulter.workerName | dashIfEmpty}}
</span>
</vn-td>
<vn-td expand>
<vn-textarea
vn-three
disabled="true"
label="Observation"
ng-model="defaulter.observation">
</vn-textarea>
</vn-td>
<vn-td number>{{::defaulter.creditInsurance}}</vn-td>
<vn-td shrink-datetime>{{::defaulter.defaulterSinced | date: 'dd/MM/yyyy'}}</vn-td>
</vn-tr>
</vn-tbody>
</vn-table>
</vn-card>
</vn-data-viewer>
<vn-client-descriptor-popover
vn-id="clientDescriptor">
</vn-client-descriptor-popover>
<vn-worker-descriptor-popover
vn-id="workerDescriptor">
</vn-worker-descriptor-popover>
<vn-popup vn-id="dialog-summary-client">
<vn-client-summary
client="$ctrl.clientSelected">
</vn-client-summary>
</vn-popup>
<!--Context menu-->
<vn-contextmenu vn-id="contextmenu" targets="['vn-data-viewer']" model="model"
expr-builder="$ctrl.exprBuilder(param, value)">
<slot-menu>
<vn-item translate
ng-if="contextmenu.isFilterAllowed()"
ng-click="contextmenu.filterBySelection()">
Filter by selection
</vn-item>
<vn-item translate
ng-if="contextmenu.isFilterAllowed()"
ng-click="contextmenu.excludeSelection()">
Exclude selection
</vn-item>
<vn-item translate
ng-if="contextmenu.isFilterAllowed()"
ng-click="contextmenu.removeFilter()">
Remove filter
</vn-item>
<vn-item translate
ng-click="contextmenu.removeAllFilters()">
Remove all filters
</vn-item>
<vn-item translate
ng-if="contextmenu.isActionAllowed()"
ng-click="contextmenu.copyValue()">
Copy value
</vn-item>
</slot-menu>
</vn-contextmenu>
<!-- Dialog of add notes button -->
<vn-dialog
vn-id="notesDialog"
on-accept="$ctrl.onResponse()">
<tpl-body>
<section class="SMSDialog">
<h5 class="vn-py-sm">{{$ctrl.$t('Add observation to all selected clients', {total: $ctrl.checked.length})}}</h5>
<vn-horizontal>
<vn-textarea vn-one
vn-id="message"
label="Message"
ng-model="$ctrl.defaulter.observation"
rows="3"
required="true"
rule>
</vn-textarea>
</vn-horizontal>
</section>
</tpl-body>
<tpl-buttons>
<input type="button" response="cancel" translate-attr="{value: 'Cancel'}"/>
<button response="accept" translate>Save</button>
</tpl-buttons>
</vn-dialog>

View File

@ -0,0 +1,65 @@
import ngModule from '../module';
import Section from 'salix/components/section';
import UserError from 'core/lib/user-error';
export default class Controller extends Section {
constructor($element, $) {
super($element, $);
this.defaulter = {};
}
get balanceDueTotal() {
let balanceDueTotal = 0;
if (this.checked.length > 0) {
for (let defaulter of this.checked)
balanceDueTotal += defaulter.amount;
return balanceDueTotal;
}
return balanceDueTotal;
}
get checked() {
const clients = this.$.model.data || [];
const checkedLines = [];
for (let defaulter of clients) {
if (defaulter.checked)
checkedLines.push(defaulter);
}
return checkedLines;
}
onResponse() {
if (!this.defaulter.observation)
throw new UserError(`The message can't be empty`);
const params = [];
for (let defaulter of this.checked) {
params.push({
text: this.defaulter.observation,
clientFk: defaulter.clientFk
});
}
this.$http.post(`ClientObservations`, params) .then(() => {
this.vnApp.showMessage(this.$t('Observation saved!'));
this.$state.reload();
});
}
exprBuilder(param, value) {
switch (param) {
case 'clientName':
case 'salesPersonFk':
return {[`d.${param}`]: value};
}
}
}
ngModule.vnComponent('vnClientDefaulterIndex', {
template: require('./index.html'),
controller: Controller
});

View File

@ -0,0 +1,98 @@
import './index';
import crudModel from 'core/mocks/crud-model';
describe('client defaulter', () => {
describe('Component vnClientDefaulterIndex', () => {
let controller;
let $httpBackend;
beforeEach(ngModule('client'));
beforeEach(inject(($componentController, _$httpBackend_) => {
$httpBackend = _$httpBackend_;
const $element = angular.element('<vn-client-defaulter></vn-client-defaulter>');
controller = $componentController('vnClientDefaulterIndex', {$element});
controller.$.model = crudModel;
controller.$.model.data = [
{clientFk: 1101, amount: 125},
{clientFk: 1102, amount: 500},
{clientFk: 1103, amount: 250}
];
}));
describe('checked() getter', () => {
it('should return the checked lines', () => {
const data = controller.$.model.data;
data[1].checked = true;
data[2].checked = true;
const checkedRows = controller.checked;
const firstCheckedRow = checkedRows[0];
const secondCheckedRow = checkedRows[1];
expect(firstCheckedRow.clientFk).toEqual(1102);
expect(secondCheckedRow.clientFk).toEqual(1103);
});
});
describe('balanceDueTotal() getter', () => {
it('should return balance due total', () => {
const data = controller.$.model.data;
data[1].checked = true;
data[2].checked = true;
const checkedRows = controller.checked;
const expectedAmount = checkedRows[0].amount + checkedRows[1].amount;
const result = controller.balanceDueTotal;
expect(result).toEqual(expectedAmount);
});
});
describe('onResponse()', () => {
it('should return error for empty message', () => {
let error;
try {
controller.onResponse();
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe(`The message can't be empty`);
});
it('should return saved message', () => {
const data = controller.$.model.data;
data[1].checked = true;
controller.defaulter = {observation: 'My new observation'};
const params = [{text: controller.defaulter.observation, clientFk: data[1].clientFk}];
jest.spyOn(controller.vnApp, 'showMessage');
$httpBackend.expect('POST', `ClientObservations`, params).respond(200, params);
controller.onResponse();
$httpBackend.flush();
expect(controller.vnApp.showMessage).toHaveBeenCalledWith('Observation saved!');
});
});
describe('exprBuilder()', () => {
it('should search by sales person', () => {
let expr = controller.exprBuilder('salesPersonFk', '5');
expect(expr).toEqual({'d.salesPersonFk': '5'});
});
it('should search by client name', () => {
let expr = controller.exprBuilder('clientName', '1foo');
expect(expr).toEqual({'d.clientName': '1foo'});
});
});
});
});

View File

@ -0,0 +1,7 @@
Last observation: Última observación
Add observation: Añadir observación
Search client: Buscar clientes
Add observation to all selected clients: Añadir observación a {{total}} cliente(s) seleccionado(s)
Credit I.: Crédito A.
Balance D.: Saldo V.
Worker who made the last observation: Trabajador que ha realizado la última observación

View File

@ -44,3 +44,4 @@ import './dms/create';
import './dms/edit';
import './consumption';
import './consumption-search-panel';
import './defaulter';

View File

@ -33,6 +33,7 @@ Search client by id or name: Buscar clientes por identificador o nombre
# Sections
Clients: Clientes
Defaulter: Morosos
New client: Nuevo cliente
Fiscal data: Datos fiscales
Billing data: Forma de pago

View File

@ -6,7 +6,8 @@
"dependencies": ["worker", "invoiceOut"],
"menus": {
"main": [
{"state": "client.index", "icon": "person"}
{"state": "client.index", "icon": "person"},
{"state": "client.defaulter.index", "icon": "person"}
],
"card": [
{"state": "client.card.basicData", "icon": "settings"},
@ -360,6 +361,18 @@
"params": {
"client": "$ctrl.client"
}
},
{
"url": "/defaulter",
"state": "client.defaulter",
"component": "ui-view",
"description": "Defaulter"
},
{
"url": "/index?q",
"state": "client.defaulter.index",
"component": "vn-client-defaulter-index",
"description": "Defaulter"
}
]
}