Merge pull request '2290-client_consumption' (#307) from 2290-client_consumption into dev
gitea/salix/pipeline/head This commit looks good Details

Reviewed-by: Bernat Exposito <bernat@verdnatura.es>
This commit is contained in:
Joan Sanchez 2020-06-15 07:21:16 +00:00
commit 1b71c1b3b1
28 changed files with 533 additions and 66 deletions

View File

@ -664,12 +664,12 @@ INSERT INTO `vn`.`itemCategory`(`id`, `name`, `display`, `color`, `icon`, `code`
INSERT INTO `vn`.`itemType`(`id`, `code`, `name`, `categoryFk`, `life`,`workerFk`, `isPackaging`)
VALUES
(1, 'CRI', 'Crisantemo', 2, 31, 5, 0),
(2, 'ITG', 'Anthurium', 1, 31, 5, 0),
(3, 'WPN', 'Paniculata', 2, 31, 5, 0),
(4, 'PRT', 'Delivery ports', 3, NULL, 5, 1),
(5, 'CON', 'Container', 3, NULL, 5, 1),
(6, 'ALS', 'Alstroemeria', 1, 31, 5, 0);
(1, 'CRI', 'Crisantemo', 2, 31, 35, 0),
(2, 'ITG', 'Anthurium', 1, 31, 35, 0),
(3, 'WPN', 'Paniculata', 2, 31, 35, 0),
(4, 'PRT', 'Delivery ports', 3, NULL, 35, 1),
(5, 'CON', 'Container', 3, NULL, 35, 1),
(6, 'ALS', 'Alstroemeria', 1, 31, 35, 0);
INSERT INTO `vn`.`ink`(`id`, `name`, `picture`, `showOrder`)
VALUES

View File

@ -14,7 +14,7 @@
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url('./icons/Material-Design-Icons.woff2') format('woff2');
src: url('./icons/MaterialIcons-Regular.woff2') format('woff2');
}
.material-icons {

Binary file not shown.

View File

@ -0,0 +1,122 @@
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('consumption', {
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'
}, {
arg: 'search',
type: 'String',
description: `If it's and integer searchs by id, otherwise it searchs by name`
}, {
arg: 'itemFk',
type: 'Integer',
description: 'Item id'
}, {
arg: 'categoryFk',
type: 'Integer',
description: 'Category id'
}, {
arg: 'typeFk',
type: 'Integer',
description: 'Item type id',
}, {
arg: 'buyerFk',
type: 'Integer',
description: 'Buyer id'
}, {
arg: 'from',
type: 'Date',
description: `The from date filter`
}, {
arg: 'to',
type: 'Date',
description: `The to date filter`
}, {
arg: 'grouped',
type: 'Boolean',
description: 'Group by item'
}
],
returns: {
type: ['Object'],
root: true
},
http: {
path: `/consumption`,
verb: 'GET'
}
});
Self.consumption = async(ctx, filter) => {
const conn = Self.dataSource.connector;
const args = ctx.args;
const where = buildFilter(ctx.args, (param, value) => {
switch (param) {
case 'search':
return /^\d+$/.test(value)
? {'i.id': value}
: {'i.name': {like: `%${value}%`}};
case 'itemId':
return {'i.id': value};
case 'description':
return {'i.description': {like: `%${value}%`}};
case 'categoryId':
return {'it.categoryFk': value};
case 'typeId':
return {'it.id': value};
case 'buyerId':
return {'it.workerFk': value};
}
});
filter = mergeFilters(filter, {where});
let stmt = new ParameterizedSQL('SELECT');
if (args.grouped)
stmt.merge(`SUM(s.quantity) AS quantity,`);
else
stmt.merge(`s.quantity,`);
stmt.merge(`s.itemFk,
s.concept,
s.ticketFk,
t.shipped,
i.name AS itemName,
i.size AS itemSize,
i.typeFk AS itemTypeFk,
i.subName,
i.tag5,
i.value5,
i.tag6,
i.value6,
i.tag7,
i.value7,
i.tag8,
i.value8,
i.tag9,
i.value9,
i.tag10,
i.value10
FROM sale s
JOIN ticket t ON t.id = s.ticketFk
JOIN item i ON i.id = s.itemFk
JOIN itemType it ON it.id = i.typeFk`, [args.grouped]);
stmt.merge(conn.makeWhere(filter.where));
if (args.grouped)
stmt.merge(`GROUP BY s.itemFk`);
stmt.merge(conn.makePagination(filter));
return conn.executeStmt(stmt);
};
};

View File

@ -0,0 +1,40 @@
const app = require('vn-loopback/server/server');
describe('client consumption() filter', () => {
it('should return a list of buyed items by ticket', async() => {
const ctx = {req: {accessToken: {userId: 9}}, args: {}};
const filter = {
where: {
clientFk: 101
},
order: 'itemTypeFk, itemName, itemSize'
};
const result = await app.models.Client.consumption(ctx, filter);
expect(result.length).toEqual(10);
});
it('should return a list of tickets grouped by item', async() => {
const ctx = {req: {accessToken: {userId: 9}},
args: {
grouped: true
}
};
const filter = {
where: {
clientFk: 101
},
order: 'itemTypeFk, itemName, itemSize'
};
const result = await app.models.Client.consumption(ctx, filter);
const firstRow = result[0];
const secondRow = result[1];
const thirdRow = result[2];
expect(result.length).toEqual(3);
expect(firstRow.quantity).toEqual(10);
expect(secondRow.quantity).toEqual(15);
expect(thirdRow.quantity).toEqual(20);
});
});

View File

@ -2,7 +2,6 @@ let request = require('request-promise-native');
let UserError = require('vn-loopback/util/user-error');
let getFinalState = require('vn-loopback/util/hook').getFinalState;
let isMultiple = require('vn-loopback/util/hook').isMultiple;
const httpParamSerializer = require('vn-loopback/util/http').httpParamSerializer;
const LoopBackContext = require('loopback-context');
module.exports = Self => {
@ -27,6 +26,7 @@ module.exports = Self => {
require('../methods/client/sendSms')(Self);
require('../methods/client/createAddress')(Self);
require('../methods/client/updateAddress')(Self);
require('../methods/client/consumption')(Self);
// Validations

View File

@ -0,0 +1,68 @@
<div class="search-panel">
<form class="vn-pa-lg" ng-submit="$ctrl.onSearch()">
<vn-horizontal>
<vn-textfield vn-focus
vn-one
label="General search"
ng-model="filter.search"
vn-focus>
</vn-textfield>
</vn-horizontal>
<vn-horizontal>
<vn-textfield
vn-one
label="Item id"
ng-model="filter.itemId">
</vn-textfield>
<vn-autocomplete
vn-one
ng-model="filter.buyerId"
url="Clients/activeWorkersWithRole"
search-function="{firstName: $search}"
value-field="id"
where="{role: 'employee'}"
label="Buyer">
<tpl-item>{{nickname}}</tpl-item>
</vn-autocomplete>
</vn-horizontal>
<vn-horizontal>
<vn-autocomplete vn-one
ng-model="filter.typeId"
url="ItemTypes"
show-field="name"
value-field="id"
label="Type"
fields="['categoryFk']"
include="'category'">
<tpl-item>
<div>{{name}}</div>
<div class="text-caption text-secondary">
{{category.name}}
</div>
</tpl-item>
</vn-autocomplete>
<vn-autocomplete vn-one
url="ItemCategories"
label="Category"
show-field="name"
value-field="id"
ng-model="filter.categoryId">
</vn-autocomplete>
</vn-horizontal>
<vn-horizontal>
<vn-date-picker
vn-one
label="From"
ng-model="filter.from">
</vn-date-picker>
<vn-date-picker
vn-one
label="To"
ng-model="filter.to">
</vn-date-picker>
</vn-horizontal>
<vn-horizontal class="vn-mt-lg">
<vn-submit label="Search"></vn-submit>
</vn-horizontal>
</form>
</div>

View File

@ -0,0 +1,7 @@
import ngModule from '../module';
import SearchPanel from 'core/components/searchbar/search-panel';
ngModule.component('vnConsumptionSearchPanel', {
template: require('./index.html'),
controller: SearchPanel
});

View File

@ -0,0 +1,3 @@
Item id: Id artículo
From: Desde
To: Hasta

View File

@ -0,0 +1,91 @@
<vn-crud-model vn-id="model"
url="Clients/consumption"
link="{clientFk: $ctrl.$params.id}"
filter="::$ctrl.filter"
user-params="::$ctrl.filterParams"
data="sales"
order="itemTypeFk, itemName, itemSize">
</vn-crud-model>
<vn-portal slot="topbar">
<vn-searchbar
panel="vn-consumption-search-panel"
suggested-filter="$ctrl.filterParams"
info="Search by item id or name"
model="model"
auto-state="false">
</vn-searchbar>
</vn-portal>
<vn-data-viewer model="model">
<vn-card class="vn-pa-lg vn-w-lg">
<section class="header">
<vn-tool-bar class="vn-mb-md">
<vn-button disabled="!model.userParams.from || !model.userParams.to"
icon="picture_as_pdf"
ng-click="$ctrl.showReport()"
vn-tooltip="Open as PDF">
</vn-button>
<vn-button disabled="!model.userParams.from || !model.userParams.to"
icon="email"
ng-click="confirm.show()"
vn-tooltip="Send to email">
</vn-button>
<vn-check
label="Group by item"
on-change="$ctrl.changeGrouped(value)">
</vn-check>
</vn-tool-bar>
</section>
<vn-table model="model">
<vn-thead>
<vn-tr>
<vn-th field="itemFk" number>Item</vn-th>
<vn-th field="ticketFk" number>Ticket</vn-th>
<vn-th field="shipped">Fecha</vn-th>
<vn-th expand>Description</vn-th>
<vn-th field="quantity" number>Quantity</vn-th>
</vn-tr>
</vn-thead>
<vn-tbody>
<vn-tr
ng-repeat="sale in sales">
<vn-td number>
<span
ng-click="itemDescriptor.show($event, sale.itemFk)"
class="link">
{{::sale.itemFk}}
</span>
</vn-td>
<vn-td number>
<span
ng-click="ticketDescriptor.show($event, sale.ticketFk)"
class="link">
{{::sale.ticketFk}}
</span>
</vn-td>
<vn-td>{{::sale.shipped | date: 'dd/MM/yyyy'}}</vn-td>
<vn-td expand>
<vn-fetched-tags
max-length="6"
item="::sale"
name="::sale.concept"
sub-name="::sale.subName">
</vn-fetched-tags>
</vn-td>
<vn-td number>{{::sale.quantity | dashIfEmpty}}</vn-td>
</vn-tr>
</vn-tbody>
</vn-table>
</vn-card>
</vn-data-viewer>
<vn-item-descriptor-popover
vn-id="item-descriptor">
</vn-item-descriptor-popover>
<vn-ticket-descriptor-popover
vn-id="ticket-descriptor">
</vn-ticket-descriptor-popover>
<vn-confirm
vn-id="confirm"
question="Please, confirm"
message="The consumption report will be sent"
on-accept="$ctrl.sendEmail()">
</vn-confirm>

View File

@ -0,0 +1,69 @@
import ngModule from '../module';
import Section from 'salix/components/section';
class Controller extends Section {
constructor($element, $, vnReport, vnEmail) {
super($element, $);
this.vnReport = vnReport;
this.vnEmail = vnEmail;
this.filter = {
where: {
isPackaging: false
}
};
const minDate = new Date();
minDate.setHours(0, 0, 0, 0);
minDate.setMonth(minDate.getMonth() - 2);
const maxDate = new Date();
maxDate.setHours(23, 59, 59, 59);
this.filterParams = {
from: minDate,
to: maxDate
};
}
get reportParams() {
const userParams = this.$.model.userParams;
return Object.assign({
authorization: this.vnToken.token,
recipientId: this.client.id
}, userParams);
}
showTicketDescriptor(event, sale) {
if (!sale.isTicket) return;
this.$.ticketDescriptor.show(event.target, sale.origin);
}
showReport() {
this.vnReport.show('campaign-metrics', this.reportParams);
}
sendEmail() {
this.vnEmail.send('campaign-metrics', this.reportParams);
}
changeGrouped(value) {
const model = this.$.model;
model.addFilter({}, {grouped: value});
}
}
Controller.$inject = ['$element', '$scope', 'vnReport', 'vnEmail'];
ngModule.component('vnClientConsumption', {
template: require('./index.html'),
controller: Controller,
bindings: {
client: '<'
},
require: {
card: '^vnClientCard'
}
});

View File

@ -0,0 +1,72 @@
import './index.js';
import crudModel from 'core/mocks/crud-model';
describe('Client', () => {
describe('Component vnClientConsumption', () => {
let $scope;
let controller;
let $httpParamSerializer;
let $httpBackend;
beforeEach(ngModule('client'));
beforeEach(angular.mock.inject(($componentController, $rootScope, _$httpParamSerializer_, _$httpBackend_) => {
$scope = $rootScope.$new();
$httpParamSerializer = _$httpParamSerializer_;
$httpBackend = _$httpBackend_;
const $element = angular.element('<vn-client-consumption></vn-client-consumption');
controller = $componentController('vnClientConsumption', {$element, $scope});
controller.$.model = crudModel;
controller.client = {
id: 101
};
}));
describe('showReport()', () => {
it('should call the window.open function', () => {
jest.spyOn(window, 'open').mockReturnThis();
const now = new Date();
controller.$.model.userParams = {
from: now,
to: now
};
controller.showReport();
const expectedParams = {
recipientId: 101,
from: now,
to: now
};
const serializedParams = $httpParamSerializer(expectedParams);
const path = `api/report/campaign-metrics?${serializedParams}`;
expect(window.open).toHaveBeenCalledWith(path);
});
});
describe('sendEmail()', () => {
it('should make a GET query sending the report', () => {
const now = new Date();
controller.$.model.userParams = {
from: now,
to: now
};
const expectedParams = {
recipientId: 101,
from: now,
to: now
};
const serializedParams = $httpParamSerializer(expectedParams);
const path = `email/campaign-metrics?${serializedParams}`;
$httpBackend.expect('GET', path).respond({});
controller.sendEmail();
$httpBackend.flush();
});
});
});
});

View File

@ -0,0 +1,6 @@
Group by item: Agrupar por artículo
Open as PDF: Abrir como PDF
Send to email: Enviar por email
Search by item id or name: Buscar por id de artículo o nombre
The consumption report will be sent: Se enviará el informe de consumo
Please, confirm: Por favor, confirma

View File

@ -13,11 +13,6 @@
translate>
Send SMS
</vn-item>
<vn-item
ng-click="consumerReportDialog.show()"
translate>
View consumer report
</vn-item>
</slot-menu>
<slot-body>
<div class="attributes">
@ -94,28 +89,4 @@
<vn-client-sms
vn-id="sms"
sms="$ctrl.newSMS">
</vn-client-sms>
<vn-dialog
vn-id="consumerReportDialog"
on-accept="$ctrl.onConsumerReportAccept()"
message="Send consumer report">
<tpl-body>
<vn-date-picker
vn-id="from"
vn-one
ng-model="$ctrl.from"
label="From date"
vn-focus>
</vn-date-picker>
<vn-date-picker
vn-id="to"
vn-one
ng-model="$ctrl.to"
label="To date">
</vn-date-picker>
</tpl-body>
<tpl-buttons>
<input type="button" response="cancel" translate-attr="{value: 'Cancel'}"/>
<button response="accept" translate>Accept</button>
</tpl-buttons>
</vn-dialog>
</vn-client-sms>

View File

@ -39,14 +39,6 @@ class Controller extends Descriptor {
};
this.$.sms.open();
}
onConsumerReportAccept() {
this.vnReport.show('campaign-metrics', {
recipientId: this.id,
from: this.from,
to: this.to,
});
}
}
ngModule.vnComponent('vnClientDescriptor', {

View File

@ -40,3 +40,5 @@ import './postcode';
import './dms/index';
import './dms/create';
import './dms/edit';
import './consumption';
import './consumption-search-panel';

View File

@ -56,4 +56,5 @@ Requested credits: Créditos solicitados
Contacts: Contactos
Samples: Plantillas
Send sample: Enviar plantilla
Log: Historial
Log: Historial
Consumption: Consumo

View File

@ -18,16 +18,17 @@
{"state": "client.card.greuge.index", "icon": "work"},
{"state": "client.card.balance.index", "icon": "icon-invoices"},
{"state": "client.card.recovery.index", "icon": "icon-recovery"},
{"state": "client.card.webAccess", "icon": "cloud"},
{"state": "client.card.log", "icon": "history"},
{
"description": "Others",
"icon": "more",
"childs": [
{"state": "client.card.webAccess", "icon": "cloud"},
{"state": "client.card.sample.index", "icon": "mail"},
{"state": "client.card.consumption", "icon": "show_chart"},
{"state": "client.card.mandate", "icon": "pan_tool"},
{"state": "client.card.creditInsurance.index", "icon": "icon-solunion"},
{"state": "client.card.contact", "icon": "contact_phone"},
{"state": "client.card.sample.index", "icon": "mail"},
{"state": "client.card.webPayment", "icon": "icon-onlinepayment"},
{"state": "client.card.dms.index", "icon": "cloud_upload"}
]
@ -350,6 +351,15 @@
"params": {
"client": "$ctrl.client"
}
},
{
"url": "/consumption",
"state": "client.card.consumption",
"component": "vn-client-consumption",
"description": "Consumption",
"params": {
"client": "$ctrl.client"
}
}
]
}

View File

@ -46,6 +46,7 @@
}
.privacy {
text-align: center;
padding: 20px 0;
font-size: 10px;
font-weight: 100

View File

@ -1,5 +1,10 @@
header {
text-align: center
}
header .logo {
margin-bottom: 15px;
margin-top: 25px;
margin-bottom: 25px
}
header .logo img {

View File

@ -2,5 +2,6 @@ const Vue = require('vue');
const strftime = require('strftime');
Vue.filter('date', function(value, specifiers = '%d-%m-%Y') {
if (!(value instanceof Date)) value = new Date(value);
return strftime(specifiers, value);
});

View File

@ -1,6 +1,6 @@
[
{
"filename": "campaing-metrics",
"filename": "campaign-metrics.pdf",
"component": "campaign-metrics"
}
]

View File

@ -25,7 +25,7 @@
<div class="grid-block vn-pa-lg">
<h1>{{ $t('title') }}</h1>
<p>{{$t('dear')}},</p>
<p>{{$t('description')}}</p>
<p v-html="$t('description', [minDate, maxDate])"></p>
</div>
</div>
<!-- Footer block -->

View File

@ -4,7 +4,17 @@ const emailFooter = new Component('email-footer');
module.exports = {
name: 'campaign-metrics',
created() {
this.filters = this.$options.filters;
},
computed: {
minDate: function() {
return this.filters.date(this.from, '%d-%m-%Y');
},
maxDate: function() {
return this.filters.date(this.to, '%d-%m-%Y');
}
},
components: {
'email-header': emailHeader.build(),
'email-footer': emailFooter.build()

View File

@ -1,7 +1,8 @@
subject: Informe consumo campaña
title: Informe consumo campaña
subject: Informe de consumo
title: Informe de consumo
dear: Estimado cliente
description: Con motivo de esta próxima campaña, me complace
description: Tal y como nos ha solicitado nos complace
relacionarle a continuación el consumo que nos consta en su cuenta para las
mismas fechas del año pasado. Espero le sea de utilidad para preparar su pedido.
fechas comprendidas entre <strong>{0}</strong> y <strong>{1}</strong>.
Espero le sea de utilidad para preparar su pedido.<br/><br/>
Al mismo tiempo aprovecho la ocasión para saludarle cordialmente.

View File

@ -6,9 +6,6 @@ const reportFooter = new Component('report-footer');
module.exports = {
name: 'campaign-metrics',
async serverPrefetch() {
this.to = new Date(this.to);
this.from = new Date(this.from);
this.client = await this.fetchClient(this.recipientId);
this.sales = await this.fetchSales(this.recipientId, this.from, this.to);
@ -54,7 +51,7 @@ module.exports = {
t.clientFk = ? AND it.isPackaging = FALSE
AND DATE(t.shipped) BETWEEN ? AND ?
GROUP BY s.itemFk
ORDER BY i.typeFk , i.name , i.size`, [clientId, from, to]);
ORDER BY i.typeFk , i.name`, [clientId, from, to]);
},
},
components: {
@ -66,12 +63,10 @@ module.exports = {
required: true
},
from: {
required: true,
type: Date
required: true
},
to: {
required: true,
type: Date
required: true
}
}
};

View File

@ -1,4 +1,4 @@
title: Consumo de campaña
title: Consumo
Client: Cliente
clientData: Datos del cliente
dated: Fecha