supplier consumption

This commit is contained in:
Bernat Exposito 2020-12-16 08:05:41 +01:00
parent ccc651a445
commit e1ac64a8d9
20 changed files with 795 additions and 1 deletions

View File

@ -0,0 +1,165 @@
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: 'itemId',
type: 'Number',
description: 'Item id'
}, {
arg: 'categoryId',
type: 'Number',
description: 'Category id'
}, {
arg: 'typeId',
type: 'Number',
description: 'Item type id',
}, {
arg: 'buyerId',
type: 'Number',
description: 'Buyer id'
}, {
arg: 'from',
type: 'Date',
description: `The from date filter`
}, {
arg: 'to',
type: 'Date',
description: `The to date filter`
}
],
returns: {
type: ['Object'],
root: true
},
http: {
path: `/consumption`,
verb: 'GET'
}
});
Self.consumption = async(ctx, filter) => {
const conn = Self.dataSource.connector;
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};
case 'from':
return {'e.shipped': {gte: value}};
case 'to':
return {'e.shipped': {lte: value}};
}
});
filter = mergeFilters(filter, {where});
let stmts = [];
let stmt;
stmts.push('DROP TEMPORARY TABLE IF EXISTS tmp.entry');
stmt = new ParameterizedSQL(
`CREATE TEMPORARY TABLE tmp.entry
(INDEX (id))
ENGINE = MEMORY
SELECT
e.id,
e.ref,
e.supplierFk,
t.shipped
FROM vn.entry e
JOIN vn.travel t ON t.id = e.travelFk`);
stmt.merge(conn.makeGroupBy('e.id'));
stmts.push(stmt);
const entriesIndex = stmts.push('SELECT * FROM tmp.entry') - 1;
stmt = new ParameterizedSQL(
`SELECT
b.id AS buyId,
b.itemFk,
b.entryFk,
b.quantity,
CAST(b.buyingValue AS DECIMAL(10,2)) AS price,
CAST(SUM(b.buyingValue*b.quantity)AS DECIMAL(10,2)) AS total,
i.id,
i.description,
i.name AS itemName,
i.subName,
i.size AS itemSize,
i.typeFk AS itemTypeFk,
i.tag5,
i.value5,
i.tag6,
i.value6,
i.tag7,
i.value7,
i.tag8,
i.value8,
i.tag9,
i.value9,
i.tag10,
i.value10,
it.id,
it.workerFk,
it.categoryFk,
it.code AS itemTypeCode
FROM buy b
JOIN tmp.entry e ON e.id = b.entryFk
JOIN item i ON i.id = b.itemFk
JOIN itemType it ON it.id = i.typeFk`
);
stmt.merge(conn.makeWhere(filter.where));
stmt.merge(conn.makeGroupBy('b.id'));
const buysIndex = stmts.push(stmt) - 1;
stmts.push(`DROP TEMPORARY TABLE tmp.entry`);
const sql = ParameterizedSQL.join(stmts, ';');
stmt.merge(conn.makePagination(filter));
const result = await conn.executeStmt(sql);
const entries = result[entriesIndex];
const buys = result[buysIndex];
const entriesMap = new Map();
for (let entry of entries)
entriesMap.set(entry.id, entry);
for (let buy of buys) {
const entry = entriesMap.get(buy.entryFk);
if (entry) {
if (!entry.buys) entry.buys = [];
entry.buys.push(buy);
}
}
return entries;
};
};

View File

@ -0,0 +1,36 @@
const app = require('vn-loopback/server/server');
describe('supplier consumption() filter', () => {
it('should return a list of entries from the supplier 2', async() => {
const ctx = {req: {accessToken: {userId: 9}}, args: {}};
const filter = {
where: {
supplierFk: 2
},
order: 'itemTypeFk, itemName, itemSize'
};
const result = await app.models.Supplier.consumption(ctx, filter);
expect(result.length).toEqual(8);
});
it('should return a list of entries from the item id 1 and supplier 1', async() => {
const ctx = {req: {accessToken: {userId: 9}},
args: {
itemId: 1
}
};
const filter = {
where: {
supplierFk: 1
},
order: 'itemTypeFk, itemName, itemSize'
};
const result = await app.models.Supplier.consumption(ctx, filter);
const expectedItemId = 1;
const firstRowBuys = result[0].buys[0];
expect(firstRowBuys.itemFk).toEqual(expectedItemId);
});
});

View File

@ -5,6 +5,7 @@ module.exports = Self => {
require('../methods/supplier/filter')(Self);
require('../methods/supplier/getSummary')(Self);
require('../methods/supplier/updateFiscalData')(Self);
require('../methods/supplier/consumption')(Self);
Self.validatesPresenceOf('name', {
message: 'The social name cannot be empty'

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,15 @@
import ngModule from '../module';
import SearchPanel from 'core/components/searchbar/search-panel';
class Controller extends SearchPanel {
constructor($, $element) {
super($, $element);
this.filter = this.$.filter;
}
}
ngModule.vnComponent('vnSupplierConsumptionSearchPanel', {
template: require('./index.html'),
controller: Controller
});

View File

@ -0,0 +1,7 @@
Item id: Id artículo
From: Desde
To: Hasta
Campaign: Campaña
allSaints: Día de todos los Santos
valentinesDay: Día de San Valentín
mothersDay: Día de la madre

View File

@ -0,0 +1,82 @@
<vn-crud-model vn-id="model"
url="Suppliers/consumption"
link="{supplierFk: $ctrl.$params.id}"
limit="20"
user-params="::$ctrl.filterParams"
data="entries"
order="itemTypeFk, itemName, itemSize">
</vn-crud-model>
<vn-portal slot="topbar">
<vn-searchbar
panel="vn-supplier-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-tool-bar>
</section>
<vn-table model="model"
ng-repeat="entry in entries"
ng-if="entry.buys">
<vn-thead>
<vn-tr>
<vn-th field="entryFk">Entry </vn-th>
<vn-td >{{::entry.id}}</vn-td>
<vn-th field="data">Date</vn-th>
<vn-td>{{::entry.shipped | date: 'dd/MM/yyyy'}}</vn-td>
<vn-th field="ref">Reference</vn-th>
<vn-td >{{::entry.ref}}</vn-td>
</vn-tr>
</vn-thead>
<vn-tbody>
<vn-tr ng-repeat="buy in entry.buys">
<vn-td expand>
<span>
{{::buy.itemName}}
</span>
</vn-td>
<vn-td expand>
<vn-fetched-tags
max-length="6"
item="::buy">
</vn-fetched-tags>
</vn-td>
<vn-td number>{{::buy.quantity | dashIfEmpty}}</vn-td>
<vn-td number>{{::buy.price | dashIfEmpty}}</vn-td>
<vn-td number>{{::buy.total | dashIfEmpty}}</vn-td>
<vn-td></vn-td>
</vn-tr>
<vn-tr>
<vn-td>
<vn-label-value
label="Total entry"
value="{{$ctrl.getTotal(entry)}}">
</vn-label-value>
</vn-td>
</vn-tr>
</vn-tbody>
</vn-table>
</vn-card>
</vn-data-viewer>
<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,65 @@
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.setDefaultFilter();
}
setDefaultFilter() {
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.supplier.id
}, userParams);
}
showReport() {
this.vnReport.show('supplier-campaign-metrics', this.reportParams);
}
sendEmail() {
this.vnEmail.send('supplier-campaign-metrics', this.reportParams);
}
getTotal(entry) {
if (entry.buys) {
let total = 0;
for (let buy of entry.buys)
total += buy.total;
return total;
}
}
}
Controller.$inject = ['$element', '$scope', 'vnReport', 'vnEmail'];
ngModule.vnComponent('vnSupplierConsumption', {
template: require('./index.html'),
controller: Controller,
bindings: {
supplier: '<'
},
require: {
card: '^vnSupplierCard'
}
});

View File

@ -0,0 +1,72 @@
import './index.js';
import crudModel from 'core/mocks/crud-model';
describe('Supplier', () => {
describe('Component vnSupplierConsumption', () => {
let $scope;
let controller;
let $httpParamSerializer;
let $httpBackend;
beforeEach(ngModule('supplier'));
beforeEach(inject(($componentController, $rootScope, _$httpParamSerializer_, _$httpBackend_) => {
$scope = $rootScope.$new();
$httpParamSerializer = _$httpParamSerializer_;
$httpBackend = _$httpBackend_;
const $element = angular.element('<vn-supplier-consumption></vn-supplier-consumption');
controller = $componentController('vnSupplierConsumption', {$element, $scope});
controller.$.model = crudModel;
controller.supplier = {
id: 2
};
}));
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: 2,
from: now,
to: now
};
const serializedParams = $httpParamSerializer(expectedParams);
const path = `api/report/supplier-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: 2,
from: now,
to: now
};
const serializedParams = $httpParamSerializer(expectedParams);
const path = `email/supplier-campaign-metrics?${serializedParams}`;
$httpBackend.expect('GET', path).respond({});
controller.sendEmail();
$httpBackend.flush();
});
});
});
});

View File

@ -0,0 +1,2 @@
Total entry: Total entrada

View File

@ -10,3 +10,5 @@ import './basic-data';
import './fiscal-data';
import './contact';
import './log';
import './consumption';
import './consumption-search-panel';

View File

@ -12,7 +12,8 @@
{"state": "supplier.card.basicData", "icon": "settings"},
{"state": "supplier.card.fiscalData", "icon": "account_balance"},
{"state": "supplier.card.contact", "icon": "contact_phone"},
{"state": "supplier.card.log", "icon": "history"}
{"state": "supplier.card.log", "icon": "history"},
{"state": "supplier.card.consumption", "icon": "show_chart"}
]
},
"routes": [
@ -75,6 +76,15 @@
"params": {
"supplier": "$ctrl.supplier"
}
},
{
"url": "/consumption?q",
"state": "supplier.card.consumption",
"component": "vn-supplier-consumption",
"description": "Consumption",
"params": {
"supplier": "$ctrl.supplier"
}
}
]
}

View File

@ -0,0 +1,9 @@
const Stylesheet = require(`${appPath}/core/stylesheet`);
module.exports = new Stylesheet([
`${appPath}/common/css/spacing.css`,
`${appPath}/common/css/misc.css`,
`${appPath}/common/css/layout.css`,
`${appPath}/common/css/report.css`,
`${__dirname}/style.css`])
.mergeStyles();

View File

@ -0,0 +1,11 @@
.column-oriented {
margin-top: 50px !important;
}
.bottom-line > tr {
border-bottom: 1px solid #ccc;
}
.bottom-line tr:nth-last-child() {
border-bottom: none;
}

View File

@ -0,0 +1,13 @@
title: Consumo
Supplier: Proveedor
supplierData: Datos del proveedor
dated: Fecha
From: Desde
To: Hasta
supplier: Proveedor {0}
reference: Referencia
Quantity: Cantidad
entry: Entrada
itemName: Artículo
price: Precio
total: Total

View File

@ -0,0 +1,33 @@
SELECT
b.id AS buyId,
b.itemFk,
b.entryFk,
CAST(b.buyingValue AS DECIMAL(10,2)) AS price,
b.quantity,
i.id,
i.description,
i.name AS itemName,
i.subName,
i.size AS itemSize,
i.typeFk AS itemTypeFk,
i.tag5,
i.value5,
i.tag6,
i.value6,
i.tag7,
i.value7,
i.tag8,
i.value8,
i.tag9,
i.value9,
i.tag10,
i.value10,
it.id,
it.workerFk,
it.categoryFk,
it.code AS itemTypeCode
FROM buy b
JOIN item i ON i.id = b.itemFk
JOIN itemType it ON it.id = i.typeFk
WHERE b.entryFk IN(:entriesId) AND b.quantity > 0
ORDER BY i.typeFk , i.name

View File

@ -0,0 +1,8 @@
SELECT
e.id,
e.ref,
e.supplierFk,
t.shipped
FROM vn.entry e
JOIN vn.travel t ON t.id = e.travelFk
WHERE e.supplierFk = ? AND DATE(t.shipped) BETWEEN ? AND ?

View File

@ -0,0 +1,12 @@
SELECT
s.street,
s.city,
s.postcode,
s.id,
s.name AS supplierName,
p.name AS province,
co.country
FROM supplier s
JOIN province p ON s.provinceFk = p.id
JOIN country co ON s.countryFk = co.id
WHERE s.id = ?

View File

@ -0,0 +1,122 @@
<!DOCTYPE html>
<html v-bind:lang="$i18n.locale">
<body>
<table class="grid">
<tbody>
<tr>
<td>
<!-- Header block -->
<report-header v-bind="$props"></report-header>
<!-- Block -->
<div class="grid-row">
<div class="grid-block">
<div class="columns">
<div class="size50">
<h1 class="title uppercase">{{$t('title')}}</h1>
<div class="size75">
<table class="row-oriented">
<tbody>
<tr>
<td class="font gray">{{$t('Supplier')}}</td>
<th>{{supplier.id}}</th>
</tr>
<tr>
<td class="font gray">{{$t('From')}}</td>
<th>{{from | date('%d-%m-%Y')}}</th>
</tr>
<tr>
<td class="font gray">{{$t('To')}}</td>
<th>{{to | date('%d-%m-%Y')}}</th>
</tr>
</tbody>
</table>
</div>
</div>
<div class="size50">
<div class="panel">
<div class="header">{{$t('supplierData')}}</div>
<div class="body">
<h3 class="uppercase">{{supplier.supplierName}}</h3>
<div>
{{supplier.street}}
</div>
<div>
{{supplier.postcode}}, {{supplier.city}} ({{supplier.province}})
</div>
<div>
{{supplier.country}}
</div>
</div>
</div>
</div>
</div>
<div class="no-page-break" v-for="entry in entries" v-if="entry.buys">
<table class="column-oriented repeatable">
<thead>
<tr>
<th class="align-right">{{$t('entry')}}</th>
<td>{{entry.id}}</td>
<th class="align-right">{{$t('dated')}}</th>
<td>{{entry.shipped | date('%d-%m-%Y')}}</td>
<th class="align-right">{{$t('reference')}}</th>
<td>{{entry.ref}}</td>
</tr>
</thead>
</table>
<div class="no-page-break">
<table>
<thead>
<tr class="column-oriented">
<th width="50%">{{$t('itemName')}}</th>
<th>{{$t('Quantity')}}</th>
<th>{{$t('price')}}</th>
<th>{{$t('total')}}</th>
</tr>
</thead>
<tbody v-for="buy in entry.buys">
<tr>
<td width="50%">{{buy.itemName}}</td>
<td>{{buy.quantity}}</td>
<td>{{buy.price | currency('EUR', $i18n.locale)}}</td>
<td>{{buy.quantity * buy.price | currency('EUR', $i18n.locale)}}</td>
</tr>
<tr>
<td></td>
<td colspan="2">
<div v-if="buy.value5">
<strong class="font gray">{{buy.tag5}}</strong>
<span>{{buy.value5}}</span>
</div>
</td>
<td colspan="2" class="centered">
<div v-if="buy.value6">
<strong class="font gray">{{buy.tag6}}</strong>
<span>{{buy.value6}}</span>
</div>
</td>
<td colspan="2" class="align-right">
<div v-if="buy.value7">
<strong class="font gray">{{buy.tag7}}</strong>
<span>{{buy.value7}}</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Footer block -->
<report-footer id="pageFooter"
v-bind:left-text="$t('supplier', [supplier.id])"
v-bind:center-text="supplier.supplierName"
v-bind="$props">
</report-footer>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@ -0,0 +1,61 @@
const Component = require(`${appPath}/core/component`);
const reportHeader = new Component('report-header');
const reportFooter = new Component('report-footer');
module.exports = {
name: 'supplier-campaign-metrics',
async serverPrefetch() {
this.supplier = await this.fetchSupplier(this.recipientId);
let entries = await this.fetchEntries(this.recipientId, this.from, this.to);
const entriesId = [];
for (let entry of entries)
entriesId.push(entry.id);
const buys = await this.fetchBuys(entriesId);
const entriesMap = new Map();
for (let entry of entries)
entriesMap.set(entry.id, entry);
for (let buy of buys) {
const entry = entriesMap.get(buy.entryFk);
if (entry) {
if (!entry.buys) entry.buys = [];
entry.buys.push(buy);
}
}
this.entries = entries;
if (!this.supplier)
throw new Error('Something went wrong');
},
methods: {
fetchSupplier(supplierId) {
return this.findOneFromDef('supplier', [supplierId]);
},
fetchEntries(supplierId, from, to) {
return this.rawSqlFromDef('entries', [supplierId, from, to]);
},
fetchBuys(entriesId) {
return this.rawSqlFromDef('buys', {entriesId});
}
},
components: {
'report-header': reportHeader.build(),
'report-footer': reportFooter.build()
},
props: {
recipientId: {
required: true
},
from: {
required: true
},
to: {
required: true
}
}
};