feat: add subsection Global invoicing

This commit is contained in:
Vicent Llopis 2022-12-27 12:21:04 +01:00
parent 7731ca143e
commit e324716f2f
10 changed files with 361 additions and 221 deletions

View File

@ -68,63 +68,57 @@ module.exports = Self => {
const client = await models.Client.findById(args.clientId, { const client = await models.Client.findById(args.clientId, {
fields: ['id', 'hasToInvoiceByAddress'] fields: ['id', 'hasToInvoiceByAddress']
}, myOptions); }, myOptions);
try {
if (client.hasToInvoiceByAddress) {
await Self.rawSql('CALL ticketToInvoiceByAddress(?, ?, ?, ?)', [
args.minShipped,
args.maxShipped,
args.addressId,
args.companyFk
], myOptions);
} else {
await Self.rawSql('CALL invoiceFromClient(?, ?, ?)', [
args.maxShipped,
client.id,
args.companyFk
], myOptions);
}
// Make invoice if (client.hasToInvoiceByAddress) {
const isSpanishCompany = await getIsSpanishCompany(args.companyFk, myOptions); await Self.rawSql('CALL ticketToInvoiceByAddress(?, ?, ?, ?)', [
args.minShipped,
// Validates ticket nagative base args.maxShipped,
const hasAnyNegativeBase = await getNegativeBase(myOptions); args.addressId,
if (hasAnyNegativeBase && isSpanishCompany) args.companyFk
return tx.rollback(); ], myOptions);
} else {
query = `SELECT invoiceSerial(?, ?, ?) AS serial`; await Self.rawSql('CALL invoiceFromClient(?, ?, ?)', [
const [invoiceSerial] = await Self.rawSql(query, [ args.maxShipped,
client.id, client.id,
args.companyFk, args.companyFk
'G'
], myOptions); ], myOptions);
const serialLetter = invoiceSerial.serial;
query = `CALL invoiceOut_new(?, ?, NULL, @invoiceId)`;
await Self.rawSql(query, [
serialLetter,
args.invoiceDate
], myOptions);
const [newInvoice] = await Self.rawSql(`SELECT @invoiceId id`, null, myOptions);
if (newInvoice.id) {
await Self.rawSql('CALL invoiceOutBooking(?)', [newInvoice.id], myOptions);
invoiceId = newInvoice.id;
}
} catch (e) {
const failedClient = {
id: client.id,
stacktrace: e
};
await notifyFailures(ctx, failedClient, myOptions);
} }
invoiceOut = await models.InvoiceOut.findById(invoiceId, { // Make invoice
include: { const isSpanishCompany = await getIsSpanishCompany(args.companyFk, myOptions);
relation: 'client'
} // Validates ticket nagative base
}, myOptions); const hasAnyNegativeBase = await getNegativeBase(myOptions);
if (hasAnyNegativeBase && isSpanishCompany)
return tx.rollback();
query = `SELECT invoiceSerial(?, ?, ?) AS serial`;
const [invoiceSerial] = await Self.rawSql(query, [
client.id,
args.companyFk,
'G'
], myOptions);
const serialLetter = invoiceSerial.serial;
query = `CALL invoiceOut_new(?, ?, NULL, @invoiceId)`;
await Self.rawSql(query, [
serialLetter,
args.invoiceDate
], myOptions);
if (client.id == 1102)
throw new Error('Error1');
const [newInvoice] = await Self.rawSql(`SELECT @invoiceId id`, null, myOptions);
if (newInvoice.id) {
await Self.rawSql('CALL invoiceOutBooking(?)', [newInvoice.id], myOptions);
invoiceOut = await models.InvoiceOut.findById(newInvoice.id, {
include: {
relation: 'client'
}
}, myOptions);
invoiceId = newInvoice.id;
}
if (tx) await tx.commit(); if (tx) await tx.commit();
} catch (e) { } catch (e) {
@ -132,15 +126,14 @@ module.exports = Self => {
throw e; throw e;
} }
ctx.args = { if (invoiceId) {
reference: invoiceOut.ref, ctx.args = {
recipientId: invoiceOut.clientFk, reference: invoiceOut.ref,
recipient: invoiceOut.client().email recipientId: invoiceOut.clientFk,
}; recipient: invoiceOut.client().email
try { };
await models.InvoiceOut.invoiceEmail(ctx, invoiceOut.ref); await models.InvoiceOut.invoiceEmail(ctx, invoiceOut.ref);
} catch (err) {} }
return invoiceId; return invoiceId;
}; };
@ -165,26 +158,4 @@ module.exports = Self => {
return supplierCompany && supplierCompany.total; return supplierCompany && supplierCompany.total;
} }
async function notifyFailures(ctx, failedClient, options) {
const models = Self.app.models;
const userId = ctx.req.accessToken.userId;
const $t = ctx.req.__; // $translate
const worker = await models.EmailUser.findById(userId, null, options);
const subject = $t('Global invoicing failed');
let body = $t(`Wasn't able to invoice the following clients`) + ':<br/><br/>';
body += `ID: <strong>${failedClient.id}</strong>
<br/> <strong>${failedClient.stacktrace}</strong><br/><br/>`;
await Self.rawSql(`
INSERT INTO vn.mail (sender, replyTo, sent, subject, body)
VALUES (?, ?, FALSE, ?, ?)`, [
worker.email,
worker.email,
subject,
body
], options);
}
}; };

View File

@ -0,0 +1,122 @@
<div class="vn-w-md">
<vn-data-viewer
data="data"
class="vn-w-md vn-mb-xl">
<vn-card>
<vn-table>
<vn-thead>
<vn-tr>
<vn-th field="id">Id</vn-th>
<vn-th field="name">Status</vn-th>
</vn-tr>
</vn-thead>
<vn-tbody>
<vn-tr
ng-repeat="client in data track by client.id">
<vn-td>
<span
vn-click-stop="clientDescriptor.show($event, client.id)"
class="link">
{{::client.id}}
</span>
</vn-td>
<vn-td>
<vn-spinner
ng-if="client.status == 'waiting'"
enable="true">
</vn-spinner>
<vn-icon
ng-if="client.status == 'ok'"
icon="check">
</vn-icon>
<vn-icon
class="error"
ng-if="client.status == 'error'"
icon="error">
</vn-icon>
</vn-td>
</vn-tr>
</vn-tbody>
</vn-table>
</vn-card>
</vn-data-viewer>
</div>
<vn-side-menu side="right">
<vn-crud-model
auto-load="true"
url="InvoiceOutSerials"
data="invoiceOutSerials"
order="code">
</vn-crud-model>
<vn-crud-model
auto-load="true"
url="Companies"
data="companies"
order="code">
</vn-crud-model>
<form class="vn-pa-md">
<vn-vertical>
<vn-date-picker
vn-one
label="Invoice date"
ng-model="$ctrl.invoice.invoiceDate">
</vn-date-picker>
<vn-date-picker
vn-one
label="Max date"
ng-model="$ctrl.invoice.maxShipped">
</vn-date-picker>
</vn-vertical>
<vn-horizontal>
<vn-radio
label="All clients"
val="allClients"
ng-model="$ctrl.clientsNumber"
ng-click="$ctrl.$onInit()">
</vn-radio>
<vn-radio
label="Clients range"
val="clientsRange"
ng-model="$ctrl.clientsNumber">
</vn-radio>
</vn-horizontal>
<vn-vertical ng-show="$ctrl.clientsNumber == 'clientsRange'">
<vn-autocomplete
url="Clients"
label="From client"
search-function="{or: [{id: $search}, {name: {like: '%'+$search+'%'}}]}"
order="id"
show-field="name"
value-field="id"
ng-model="$ctrl.invoice.fromClientId">
<tpl-item>{{::id}} - {{::name}}</tpl-item>
</vn-autocomplete>
<vn-autocomplete
url="Clients"
label="To client"
search-function="{or: [{id: $search}, {name: {like: '%'+$search+'%'}}]}"
order="id"
show-field="name"
value-field="id"
ng-model="$ctrl.invoice.toClientId">
<tpl-item>{{::id}} - {{::name}}</tpl-item>
</vn-autocomplete>
</vn-vertical>
<vn-horizontal>
<vn-autocomplete
url="Companies"
label="Company"
show-field="code"
value-field="id"
ng-model="$ctrl.invoice.companyFk">
</vn-autocomplete>
</vn-horizontal>
<vn-submit vn-id="invoiceButton" ng-click="$ctrl.makeInvoice()" label="Invoice" class="vn-mt-sm" ></vn-submit>
<vn-button ng-click="$ctrl.clean()" label="Clean" class="vn-mt-sm"></vn-button>
</form>
</vn-side-menu>
<vn-client-descriptor-popover
vn-id="clientDescriptor">
</vn-client-descriptor-popover>

View File

@ -1,12 +1,14 @@
import ngModule from '../../module'; import ngModule from '../module';
import Dialog from 'core/components/dialog'; import Section from 'salix/components/section';
import UserError from 'core/lib/user-error';
import './style.scss'; import './style.scss';
class Controller extends Dialog { class Controller extends Section {
constructor($element, $, $transclude) { constructor($element, $, $transclude) {
super($element, $, $transclude); super($element, $, $transclude);
this.invoice = { this.invoice = {
maxShipped: new Date() maxShipped: new Date(),
companyFk: this.vnConfig.companyFk
}; };
this.clientsNumber = 'allClients'; this.clientsNumber = 'allClients';
} }
@ -37,14 +39,6 @@ class Controller extends Dialog {
return this.$http.get('Clients/findOne', {params}); return this.$http.get('Clients/findOne', {params});
} }
get companyFk() {
return this.invoice.companyFk;
}
set companyFk(value) {
this.invoice.companyFk = value;
}
restartValues() { restartValues() {
this.lastClientId = null; this.lastClientId = null;
this.$.invoiceButton.disabled = false; this.$.invoiceButton.disabled = false;
@ -69,45 +63,51 @@ class Controller extends Dialog {
}; };
const options = this.cancelRequest(); const index = this.$.data.findIndex(element => element.id == clientAndAddress.clientId);
return this.$http.post(`InvoiceOuts/invoiceClient`, params)
return this.$http.post(`InvoiceOuts/invoiceClient`, params, options)
.then(() => { .then(() => {
this.$.data[index].status = 'ok';
}).catch(() => {
this.$.data[index].status = 'error';
}).finally(() => {
clientsAndAddresses.shift(); clientsAndAddresses.shift();
return this.invoiceOut(invoice, clientsAndAddresses); return this.invoiceOut(invoice, clientsAndAddresses);
}); });
} }
responseHandler(response) { makeInvoice() {
try { try {
if (response !== 'accept')
return super.responseHandler(response);
if (!this.invoice.invoiceDate || !this.invoice.maxShipped) if (!this.invoice.invoiceDate || !this.invoice.maxShipped)
throw new Error('Invoice date and the max date should be filled'); throw new Error('Invoice date and the max date should be filled');
if (!this.invoice.fromClientId || !this.invoice.toClientId) if (!this.invoice.fromClientId || !this.invoice.toClientId)
throw new Error('Choose a valid clients range'); throw new Error('Choose a valid clients range');
this.on('close', () => {
if (this.canceler) this.canceler.resolve();
this.vnApp.showSuccess(this.$t('Data saved!'));
});
this.$.invoiceButton.disabled = true; this.$.invoiceButton.disabled = true;
this.packageInvoicing = true; this.packageInvoicing = true;
const options = this.cancelRequest();
this.$http.post(`InvoiceOuts/clientsToInvoice`, this.invoice, options) this.$http.post(`InvoiceOuts/clientsToInvoice`, this.invoice)
.then(res => { .then(res => {
this.packageInvoicing = false; this.packageInvoicing = false;
const invoice = res.data.invoice; const invoice = res.data.invoice;
const clientsIds = [];
for (const clientAndAddress of res.data.clientsAndAddresses)
clientsIds.push(clientAndAddress.clientId);
const dataArr = new Set(clientsIds);
const clientsIdsNoRepeat = [...dataArr];
const clients = clientsIdsNoRepeat.map(clientId => {
return {
id: clientId,
status: 'waiting'
};
});
this.$.data = clients;
const clientsAndAddresses = res.data.clientsAndAddresses; const clientsAndAddresses = res.data.clientsAndAddresses;
if (!clientsAndAddresses.length) return super.responseHandler(response); if (!clientsAndAddresses.length) throw new UserError(`There aren't clients to invoice`);
this.lastClientId = clientsAndAddresses[clientsAndAddresses.length - 1].clientId;
return this.invoiceOut(invoice, clientsAndAddresses); return this.invoiceOut(invoice, clientsAndAddresses);
}) })
.then(() => super.responseHandler(response))
.then(() => this.vnApp.showSuccess(this.$t('Data saved!'))) .then(() => this.vnApp.showSuccess(this.$t('Data saved!')))
.finally(() => this.restartValues()); .finally(() => this.restartValues());
} catch (e) { } catch (e) {
@ -116,14 +116,15 @@ class Controller extends Dialog {
return false; return false;
} }
} }
clean() {
this.$.data = this.$.data.filter(client => client.status == 'error');
}
} }
Controller.$inject = ['$element', '$scope', '$transclude']; Controller.$inject = ['$element', '$scope', '$transclude'];
ngModule.vnComponent('vnInvoiceOutGlobalInvoicing', { ngModule.vnComponent('vnInvoiceOutGlobalInvoicing', {
slotTemplate: require('./index.html'), template: require('./index.html'),
controller: Controller, controller: Controller
bindings: {
companyFk: '<?'
}
}); });

View File

@ -0,0 +1,126 @@
import './index.js';
import popover from 'core/mocks/popover';
import crudModel from 'core/mocks/crud-model';
describe('Zone Component vnZoneDeliveryDays', () => {
let $httpBackend;
let controller;
let $element;
beforeEach(ngModule('zone'));
beforeEach(inject(($componentController, _$httpBackend_) => {
$httpBackend = _$httpBackend_;
$element = angular.element('<vn-zone-delivery-days></vn-zone-delivery-days');
controller = $componentController('vnZoneDeliveryDays', {$element});
controller.$.zoneEvents = popover;
controller.$.params = {};
controller.$.zoneModel = crudModel;
}));
describe('deliveryMethodFk() setter', () => {
it('should set the deliveryMethodFk property as pickup and then perform a query that sets the filter', () => {
$httpBackend.expect('GET', 'DeliveryMethods').respond([{id: 999}]);
controller.deliveryMethodFk = 'pickUp';
$httpBackend.flush();
expect(controller.agencyFilter).toEqual({deliveryMethodFk: {inq: [999]}});
});
});
describe('setParams()', () => {
it('should do nothing when no params are received', () => {
controller.setParams();
expect(controller.deliveryMethodFk).toBeUndefined();
expect(controller.geoFk).toBeUndefined();
expect(controller.agencyModeFk).toBeUndefined();
});
it('should set the controller properties when the params are provided', () => {
controller.$params = {
deliveryMethodFk: 3,
geoFk: 2,
agencyModeFk: 1
};
controller.setParams();
expect(controller.deliveryMethodFk).toEqual(controller.$params.deliveryMethodFk);
expect(controller.geoFk).toEqual(controller.$params.geoFk);
expect(controller.agencyModeFk).toEqual(controller.$params.agencyModeFk);
});
});
describe('fetchData()', () => {
it('should make an HTTP GET query and then call the showMessage() method', () => {
jest.spyOn(controller.vnApp, 'showMessage');
jest.spyOn(controller.$state, 'go');
controller.agencyModeFk = 1;
controller.deliveryMethodFk = 2;
controller.geoFk = 3;
controller.$state.current.name = 'myState';
const expectedData = {events: []};
const url = 'Zones/getEvents?agencyModeFk=1&deliveryMethodFk=2&geoFk=3';
$httpBackend.when('GET', 'DeliveryMethods').respond([]);
$httpBackend.expect('GET', url).respond({events: []});
controller.fetchData();
$httpBackend.flush();
expect(controller.$.data).toEqual(expectedData);
expect(controller.vnApp.showMessage).toHaveBeenCalledWith('No service for the specified zone');
expect(controller.$state.go).toHaveBeenCalledWith(
controller.$state.current.name,
{
agencyModeFk: 1,
deliveryMethodFk: 2,
geoFk: 3
}
);
});
});
describe('onSelection()', () => {
it('should not call the show popover method if events array is empty', () => {
jest.spyOn(controller.$.zoneEvents, 'show');
const event = new Event('click');
const target = document.createElement('div');
target.dispatchEvent(event);
const events = [];
controller.onSelection(event, events);
expect(controller.$.zoneEvents.show).not.toHaveBeenCalled();
});
it('should call the show() method and call getZoneClosing() with the expected ids', () => {
jest.spyOn(controller.$.zoneEvents, 'show');
const event = new Event('click');
const target = document.createElement('div');
target.dispatchEvent(event);
const day = new Date();
const events = [
{zoneFk: 1},
{zoneFk: 2},
{zoneFk: 8}
];
const params = {
zoneIds: [1, 2, 8],
date: [day][0]
};
const response = [{id: 1, hour: ''}];
$httpBackend.when('POST', 'Zones/getZoneClosing', params).respond({response});
controller.onSelection(event, events, [day]);
$httpBackend.flush();
expect(controller.$.zoneEvents.show).toHaveBeenCalledWith(target);
expect(controller.zoneClosing.id).toEqual(response.id);
});
});
});

View File

@ -0,0 +1,7 @@
There aren't clients to invoice: No existen clientes para facturar
Max date: Fecha límite
Invoice date: Fecha de factura
Invoice date and the max date should be filled: La fecha de factura y la fecha límite deben rellenarse
Choose a valid clients range: Selecciona un rango válido de clientes
Clients range: Rango de clientes
Calculating packages to invoice...: Calculando paquetes a factura...

View File

@ -0,0 +1,5 @@
@import "variables";
.error {
color: $color-alert;
}

View File

@ -9,4 +9,4 @@ import './descriptor';
import './descriptor-popover'; import './descriptor-popover';
import './descriptor-menu'; import './descriptor-menu';
import './index/manual'; import './index/manual';
import './index/global-invoicing'; import './global-invoicing';

View File

@ -1,96 +0,0 @@
<tpl-title translate>
Create global invoice
</tpl-title>
<tpl-body id="manifold-form">
<vn-crud-model
auto-load="true"
url="InvoiceOutSerials"
data="invoiceOutSerials"
order="code">
</vn-crud-model>
<vn-crud-model
auto-load="true"
url="Companies"
data="companies"
order="code">
</vn-crud-model>
<div
class="progress vn-my-md"
ng-if="$ctrl.packageInvoicing">
<vn-horizontal>
<div>
{{'Calculating packages to invoice...' | translate}}
</div>
</vn-horizontal>
</div>
<div
class="progress vn-my-md"
ng-if="$ctrl.lastClientId">
<vn-horizontal>
<div>
{{'Id Client' | translate}}: {{$ctrl.currentClientId}}
{{'of' | translate}} {{::$ctrl.lastClientId}}
</div>
</vn-horizontal>
</div>
<vn-horizontal>
<vn-date-picker
vn-one
label="Invoice date"
ng-model="$ctrl.invoice.invoiceDate">
</vn-date-picker>
<vn-date-picker
vn-one
label="Max date"
ng-model="$ctrl.invoice.maxShipped">
</vn-date-picker>
</vn-horizontal>
<vn-horizontal>
<vn-radio
label="All clients"
val="allClients"
ng-model="$ctrl.clientsNumber"
ng-click="$ctrl.$onInit()">
</vn-radio>
<vn-radio
label="Clients range"
val="clientsRange"
ng-model="$ctrl.clientsNumber">
</vn-radio>
</vn-horizontal>
<vn-horizontal ng-show="$ctrl.clientsNumber == 'clientsRange'">
<vn-autocomplete
url="Clients"
label="From client"
search-function="{or: [{id: $search}, {name: {like: '%'+$search+'%'}}]}"
order="id"
show-field="name"
value-field="id"
ng-model="$ctrl.invoice.fromClientId">
<tpl-item>{{::id}} - {{::name}}</tpl-item>
</vn-autocomplete>
<vn-autocomplete
url="Clients"
label="To client"
search-function="{or: [{id: $search}, {name: {like: '%'+$search+'%'}}]}"
order="id"
show-field="name"
value-field="id"
ng-model="$ctrl.invoice.toClientId">
<tpl-item>{{::id}} - {{::name}}</tpl-item>
</vn-autocomplete>
</vn-horizontal>
<vn-horizontal>
<vn-autocomplete
url="Companies"
label="Company"
show-field="code"
value-field="id"
ng-model="$ctrl.invoice.companyFk">
</vn-autocomplete>
</vn-horizontal>
</tpl-body>
<tpl-buttons>
<input type="button" response="cancel" translate-attr="{value: 'Cancel'}"/>
<button vn-id="invoiceButton" response="accept" translate>Invoice</button>{{$ctrl.isInvoicing}}
</tpl-buttons>

View File

@ -18,7 +18,7 @@
<vn-thead> <vn-thead>
<vn-tr> <vn-tr>
<vn-th shrink> <vn-th shrink>
<vn-multi-check <vn-multi-check
model="model"> model="model">
</vn-multi-check> </vn-multi-check>
</vn-th> </vn-th>
@ -37,7 +37,7 @@
class="clickable vn-tr search-result" class="clickable vn-tr search-result"
ui-sref="invoiceOut.card.summary({id: {{::invoiceOut.id}}})"> ui-sref="invoiceOut.card.summary({id: {{::invoiceOut.id}}})">
<vn-td> <vn-td>
<vn-check <vn-check
ng-model="invoiceOut.checked" ng-model="invoiceOut.checked"
vn-click-stop> vn-click-stop>
</vn-check> </vn-check>
@ -103,7 +103,3 @@
<vn-invoice-out-manual <vn-invoice-out-manual
vn-id="manual-invoicing"> vn-id="manual-invoicing">
</vn-invoice-out-manual> </vn-invoice-out-manual>
<vn-invoice-out-global-invoicing
vn-id="global-invoicing"
company-fk="$ctrl.vnConfig.companyFk">
</vn-invoice-out-global-invoicing>

View File

@ -6,7 +6,9 @@
"dependencies": ["worker", "client", "ticket"], "dependencies": ["worker", "client", "ticket"],
"menus": { "menus": {
"main": [ "main": [
{"state": "invoiceOut.index", "icon": "icon-invoice-out"} {"state": "invoiceOut.index", "icon": "icon-invoice-out"},
{"state": "invoiceOut.global-invoicing", "icon": "contact_support"}
] ]
}, },
"routes": [ "routes": [
@ -24,6 +26,12 @@
"component": "vn-invoice-out-index", "component": "vn-invoice-out-index",
"description": "InvoiceOut" "description": "InvoiceOut"
}, },
{
"url": "/global-invoicing?q",
"state": "invoiceOut.global-invoicing",
"component": "vn-invoice-out-global-invoicing",
"description": "Global invoicing"
},
{ {
"url": "/summary", "url": "/summary",
"state": "invoiceOut.card.summary", "state": "invoiceOut.card.summary",
@ -40,4 +48,4 @@
"component": "vn-invoice-out-card" "component": "vn-invoice-out-card"
} }
] ]
} }