2290 - Added consumption section
gitea/salix/pipeline/head This commit looks good Details

This commit is contained in:
Joan Sanchez 2020-06-12 09:18:19 +02:00
parent 5fbcb5c439
commit b3db5681fe
23 changed files with 450 additions and 137 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

@ -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,4 @@
Ink: Tinta
Origin: Origen
Producer: Productor
For me: Para mi

View File

@ -1,89 +1,91 @@
<vn-crud-model
vn-id="model"
url="Items/getBalance"
filter="$ctrl.filter"
<vn-crud-model vn-id="model"
url="Clients/consumption"
link="{clientFk: $ctrl.$params.id}"
filter="::$ctrl.filter"
user-params="::$ctrl.filterParams"
data="sales"
auto-load="false">
order="itemTypeFk, itemName, itemSize">
</vn-crud-model>
<vn-vertical>
<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">
<vn-vertical>
<vn-horizontal>
<vn-autocomplete
vn-focus
url="Warehouses"
show-field="name"
value-field="id"
initial-data="$ctrl.warehouseFk"
ng-model="$ctrl.warehouseFk"
label="Select warehouse">
</vn-autocomplete>
</vn-horizontal>
<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 expand>Date</vn-th>
<vn-th number order="DESC">Id</vn-th>
<vn-th>State</vn-th>
<vn-th>Reference</vn-th>
<vn-th>Client</vn-th>
<vn-th number>In</vn-th>
<vn-th number>Out</vn-th>
<vn-th number>Balance</vn-th>
<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-class="::{
'isIn': sale.in,
'balanceNegative': sale.balance < 0}"
ng-repeat="sale in sales"
vn-repeat-last
on-last="$ctrl.scrollToLine(sale.lastPreparedLineFk)"
ng-attr-id="vnItemDiary-{{::sale.lineFk}}">
<vn-td expand>
<span class="chip"
ng-class="::{warning: $ctrl.today == sale.shipped}">
{{::sale.shipped | date:'dd/MM/yyyy' }}
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-class="::{link: sale.isTicket}"
ng-click="$ctrl.showTicketDescriptor($event, sale)"
name="origin">
{{::sale.origin | dashIfEmpty}}
</span>
</vn-td>
<vn-td>{{::sale.stateName | dashIfEmpty}}</vn-td>
<vn-td>{{::sale.reference | dashIfEmpty}}</vn-td>
<vn-td class="truncate">
<span ng-if="::!sale.isTicket">
{{::sale.name | dashIfEmpty}}
</span>
<span
ng-if="::sale.isTicket"
vn-click-stop="clientDescriptor.show($event, sale.clientFk)"
<span
ng-click="ticketDescriptor.show($event, sale.ticketFk)"
class="link">
{{::sale.name | dashIfEmpty}}
{{::sale.ticketFk}}
</span>
</vn-td>
<vn-td number class="in">{{::sale.in | dashIfEmpty}}</vn-td>
<vn-td number>{{::sale.out | dashIfEmpty}}</vn-td>
<vn-td number class="balance">
<span class="chip balanceSpan"
ng-class="::{message: sale.lineFk == sale.lastPreparedLineFk}">
{{::sale.balance | dashIfEmpty}}
</span>
<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-vertical>
</vn-card>
</vn-vertical>
</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-client-descriptor-popover
vn-id="clientDescriptor">
</vn-client-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

@ -5,6 +5,31 @@ import './style.scss';
class Controller extends Section {
constructor($element, $) {
super($element, $);
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) {
@ -12,6 +37,24 @@ class Controller extends Section {
this.$.ticketDescriptor.show(event.target, sale.origin);
}
showReport() {
const params = this.reportParams;
const serializedParams = this.$httpParamSerializer(params);
window.open(`api/report/campaign-metrics?${serializedParams}`);
}
sendEmail() {
const params = this.reportParams;
this.$http.get(`email/campaign-metrics`, {params})
.then(() => this.vnApp.showMessage(this.$t('Notification sent!')));
}
changeGrouped(value) {
const model = this.$.model;
model.addFilter({}, {grouped: value});
}
}
Controller.$inject = ['$element', '$scope'];
@ -21,5 +64,8 @@ ngModule.component('vnClientConsumption', {
controller: Controller,
bindings: {
client: '<'
},
require: {
card: '^vnClientCard'
}
});

View File

@ -1,63 +1,70 @@
import './index.js';
import crudModel from 'core/mocks/crud-model';
describe('Item', () => {
describe('Component vnItemDiary', () => {
describe('Client', () => {
describe('Component vnClientConsumption', () => {
let $scope;
let controller;
let $httpParamSerializer;
let $httpBackend;
beforeEach(ngModule('item'));
beforeEach(ngModule('client'));
beforeEach(angular.mock.inject(($componentController, $rootScope) => {
beforeEach(angular.mock.inject(($componentController, $rootScope, _$httpParamSerializer_, _$httpBackend_) => {
$scope = $rootScope.$new();
const $element = angular.element('<vn-item-diary></vn-item-diary>');
controller = $componentController('vnItemDiary', {$element, $scope});
$httpParamSerializer = _$httpParamSerializer_;
$httpBackend = _$httpBackend_;
const $element = angular.element('<vn-client-consumption></vn-client-consumption');
controller = $componentController('vnClientConsumption', {$element, $scope});
controller.$.model = crudModel;
controller.$params = {id: 1};
controller.client = {
id: 101
};
}));
describe('set item()', () => {
it('should set warehouseFk property based on itemType warehouseFk', () => {
jest.spyOn(controller.$, '$applyAsync');
controller.item = {id: 1, itemType: {warehouseFk: 1}};
describe('showReport()', () => {
it('should call the window.open function', () => {
jest.spyOn(window, 'open').mockReturnThis();
expect(controller.$.$applyAsync).toHaveBeenCalledWith(jasmine.any(Function));
$scope.$apply();
const now = new Date();
controller.$.model.userParams = {
from: now,
to: now
};
expect(controller.warehouseFk).toEqual(1);
expect(controller.item.id).toEqual(1);
});
controller.showReport();
it(`should set warehouseFk property based on url query warehouseFk`, () => {
jest.spyOn(controller.$, '$applyAsync');
controller.$params.warehouseFk = 4;
controller.item = {id: 1, itemType: {warehouseFk: 1}};
const expectedParams = {
recipientId: 101,
from: now,
to: now
};
const serializedParams = $httpParamSerializer(expectedParams);
const path = `api/report/campaign-metrics?${serializedParams}`;
expect(controller.$.$applyAsync).toHaveBeenCalledWith(jasmine.any(Function));
$scope.$apply();
expect(controller.warehouseFk).toEqual(4);
expect(controller.item.id).toEqual(1);
expect(window.open).toHaveBeenCalledWith(path);
});
});
describe('scrollToLine ()', () => {
it('should assign $location then call anchorScroll using controller value', () => {
jest.spyOn(controller, '$anchorScroll');
controller.lineFk = 1;
controller.scrollToLine('invalidValue');
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
};
expect(controller.$location.hash()).toEqual(`vnItemDiary-${1}`);
expect(controller.$anchorScroll).toHaveBeenCalledWith();
});
const serializedParams = $httpParamSerializer(expectedParams);
const path = `email/campaign-metrics?${serializedParams}`;
it('should assign $location then call anchorScroll using received value', () => {
jest.spyOn(controller, '$anchorScroll');
controller.lineFk = undefined;
controller.scrollToLine(1);
expect(controller.$location.hash()).toEqual(`vnItemDiary-${1}`);
expect(controller.$anchorScroll).toHaveBeenCalledWith();
$httpBackend.expect('GET', path).respond({});
controller.sendEmail();
$httpBackend.flush();
});
});
});

View File

@ -1,4 +1,6 @@
In: Entrada
Out: Salida
Visible quantity: Cantidad visible
Ticket/Entry: Ticket/Entrada
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

@ -41,3 +41,4 @@ 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,17 +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.consumption", "icon": "show_chart"},
{"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"}
]

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 sus 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