2984 - Global invoicing endpoint
gitea/salix/pipeline/head There was a failure building this commit Details

This commit is contained in:
Joan Sanchez 2021-08-02 13:35:38 +02:00
parent 8b005ae012
commit 41a1b3d909
14 changed files with 495 additions and 16 deletions

View File

@ -127,11 +127,12 @@ INSERT INTO `vn`.`warehouseAlias`(`id`, `name`)
INSERT INTO `vn`.`warehouse`(`id`, `name`, `code`, `isComparative`, `isInventory`, `hasAvailable`, `isManaged`, `hasStowaway`, `hasDms`, `hasComission`, `aliasFk`, `countryFk`) INSERT INTO `vn`.`warehouse`(`id`, `name`, `code`, `isComparative`, `isInventory`, `hasAvailable`, `isManaged`, `hasStowaway`, `hasDms`, `hasComission`, `aliasFk`, `countryFk`)
VALUES VALUES
(1, 'Warehouse One', 'ALG', 1, 1, 1, 1, 1, 1, 1, 2, 1), (1, 'Warehouse One', 'ALG', 1, 1, 1, 1, 1, 1, 1, 2, 1),
(2, 'Warehouse Two', NULL, 1, 1, 1, 1, 0, 0, 1, 2, 13), (2, 'Warehouse Two', NULL, 1, 1, 1, 1, 0, 0, 1, 2, 13),
(3, 'Warehouse Three', NULL, 1, 1, 1, 1, 0, 0, 0, 2, 1), (3, 'Warehouse Three', NULL, 1, 1, 1, 1, 0, 0, 0, 2, 1),
(4, 'Warehouse Four', NULL, 1, 1, 1, 1, 0, 0, 0, 2, 1), (4, 'Warehouse Four', NULL, 1, 1, 1, 1, 0, 0, 0, 2, 1),
(5, 'Warehouse Five', NULL, 1, 1, 1, 1, 0, 0, 0, 2, 1); (5, 'Warehouse Five', NULL, 1, 1, 1, 1, 0, 0, 0, 2, 1),
(13, 'Inventory', NULL, 1, 1, 1, 0, 0, 0, 0, 2, 1);
INSERT INTO `vn`.`sector`(`id`, `description`, `warehouseFk`, `isPreviousPreparedByPacking`, `code`, `pickingPlacement`, `path`) INSERT INTO `vn`.`sector`(`id`, `description`, `warehouseFk`, `isPreviousPreparedByPacking`, `code`, `pickingPlacement`, `path`)
VALUES VALUES

View File

@ -192,5 +192,7 @@
"Can't invoice to past": "No se puede facturar a pasado", "Can't invoice to past": "No se puede facturar a pasado",
"This ticket is already invoiced": "Este ticket ya está facturado", "This ticket is already invoiced": "Este ticket ya está facturado",
"A ticket with an amount of zero can't be invoiced": "No se puede facturar un ticket con importe cero", "A ticket with an amount of zero can't be invoiced": "No se puede facturar un ticket con importe cero",
"A ticket with a negative base can't be invoiced": "No se puede facturar un ticket con una base negativa" "A ticket with a negative base can't be invoiced": "No se puede facturar un ticket con una base negativa",
"Not invoiceable": "Not invoiceable",
"Not invoiceable 1101": "Not invoiceable 1101"
} }

View File

@ -71,18 +71,22 @@ module.exports = Self => {
await fs.mkdir(src, {recursive: true}); await fs.mkdir(src, {recursive: true});
if (tx) await tx.commit();
const writeStream = fs.createWriteStream(fileSrc); const writeStream = fs.createWriteStream(fileSrc);
writeStream.on('open', () => { writeStream.on('open', () => {
response.pipe(writeStream); response.pipe(writeStream);
}); });
writeStream.on('finish', async function() { return await new Promise(resolve => {
writeStream.end(); writeStream.on('finish', () => {
writeStream.end();
resolve(invoiceOut);
});
}); });
if (tx) await tx.commit(); // return invoiceOut;
return invoiceOut;
} catch (e) { } catch (e) {
if (tx) await tx.rollback(); if (tx) await tx.rollback();
if (fs.existsSync(fileSrc)) if (fs.existsSync(fileSrc))

View File

@ -0,0 +1,246 @@
const UserError = require('vn-loopback/util/user-error');
module.exports = Self => {
Self.remoteMethodCtx('globalInvoicing', {
description: 'Make a global invoice',
accessType: 'WRITE',
accepts: [
{
arg: 'invoiceDate',
type: 'date',
description: 'The invoice date'
},
{
arg: 'maxShipped',
type: 'date',
description: 'The maximum shipped date'
},
{
arg: 'fromClientId',
type: 'number',
description: 'The minimum client id'
},
{
arg: 'toClientId',
type: 'number',
description: 'The maximum client id'
},
{
arg: 'companyFk',
type: 'number',
description: 'The company id to invoice'
}
],
returns: {
type: 'object',
root: true
},
http: {
path: '/globalInvoicing',
verb: 'POST'
}
});
Self.globalInvoicing = async(ctx, options) => {
const models = Self.app.models;
const args = ctx.args;
const invoicesIds = [];
let tx;
const myOptions = {};
if (typeof options == 'object')
Object.assign(myOptions, options);
if (!myOptions.transaction) {
tx = await Self.beginTransaction({});
myOptions.transaction = tx;
}
let query;
try {
query = `
SELECT MAX(issued) issued
FROM vn.invoiceOut io
JOIN vn.time t ON t.dated = io.issued
WHERE io.serial = 'A'
AND t.year = YEAR(?)
AND io.companyFk = ?`;
const [maxIssued] = await Self.rawSql(query, [
args.invoiceDate,
args.companyFk
], myOptions);
const maxSerialDate = maxIssued.issued || args.invoiceDate;
if (args.invoiceDate < maxSerialDate)
args.invoiceDate = maxSerialDate;
if (args.invoiceDate < args.maxShipped)
args.maxShipped = args.invoiceDate;
const minShipped = new Date();
minShipped.setFullYear(minShipped.getFullYear() - 1);
// Liquidacion de cubos y carros
const vIsAllInvoiceable = false;
const clientsWithPackaging = await getClientsWithPackaging(ctx, myOptions);
for (let client of clientsWithPackaging) {
await Self.rawSql('CALL packageInvoicing(?, ?, ?, ?, @newTicket)', [
client.id,
args.invoiceDate,
args.companyFk,
vIsAllInvoiceable
], myOptions);
}
const company = await models.Company.findById(args.companyFk, null, myOptions);
const invoiceableClients = await getInvoiceableClients(ctx, myOptions);
if (!invoiceableClients.length) return;
for (let client of invoiceableClients) {
// esto es para los que no tienen rol de invoicing??
/* const [clientTax] = await Self.rawSql('SELECT vn.clientTaxArea(?, ?) AS taxArea', [
client.id,
args.companyFk
], myOptions);
const clientTaxArea = clientTax.taxArea;
if (clientTaxArea != 'WORLD' && company.code === 'VNL' && hasRole('invoicing')) {
// Exit process??
console.log(clientTaxArea);
throw new UserError('Not invoiceable ' + client.id);
}
*/
if (client.hasToInvoiceByAddress) {
await Self.rawSql('CALL ticketToInvoiceByAddress(?, ?, ?, ?)', [
minShipped,
args.maxShipped,
client.addressFk,
args.companyFk
], myOptions);
} else {
await Self.rawSql('CALL invoiceFromClient(?, ?, ?)', [
args.maxShipped,
client.id,
args.companyFk
], myOptions);
}
// Make invoice
const isSpanishCompany = await getIsSpanishCompany(args.companyFk, myOptions);
// Validates ticket nagative base
const hasAnyNegativeBase = await getNegativeBase(myOptions);
if (hasAnyNegativeBase && isSpanishCompany)
continue;
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);
const [newInvoice] = await Self.rawSql(`SELECT @invoiceId id`, null, myOptions);
if (newInvoice.id) {
await Self.rawSql('CALL invoiceOutBooking(?)', [newInvoice.id], myOptions);
invoicesIds.push(newInvoice.id);
}
// IMPRIMIR PDF ID 3?
}
// Print invoice to network printer
if (tx) await tx.commit();
// Print invoices
for (let invoiceId of invoicesIds)
await Self.createPdf(ctx, invoiceId);
return {};
} catch (e) {
if (tx) await tx.rollback();
throw e;
}
};
async function getNegativeBase(options) {
const models = Self.app.models;
const query = 'SELECT hasAnyNegativeBase() AS base';
const [result] = await models.InvoiceOut.rawSql(query, null, options);
return result && result.base;
}
async function getIsSpanishCompany(companyId, options) {
const models = Self.app.models;
const query = `SELECT COUNT(*) AS total
FROM supplier s
JOIN country c ON c.id = s.countryFk
AND c.code = 'ES'
WHERE s.id = ?`;
const [supplierCompany] = await models.InvoiceOut.rawSql(query, [
companyId
], options);
return supplierCompany && supplierCompany.total;
}
async function getClientsWithPackaging(ctx, options) {
const models = Self.app.models;
const args = ctx.args;
const query = `SELECT DISTINCT clientFk AS id
FROM ticket t
JOIN ticketPackaging tp ON t.id = tp.ticketFk
WHERE t.shipped BETWEEN '2017-11-21' AND ?
AND t.clientFk BETWEEN ? AND ?`;
return models.InvoiceOut.rawSql(query, [
args.maxShipped,
args.fromClientId,
args.toClientId
], options);
}
async function getInvoiceableClients(ctx, options) {
const models = Self.app.models;
const args = ctx.args;
const minShipped = new Date();
minShipped.setFullYear(minShipped.getFullYear() - 1);
const query = `SELECT
c.id,
SUM(IFNULL(s.quantity * s.price * (100-s.discount)/100, 0) + IFNULL(ts.quantity * ts.price,0)) AS sumAmount,
c.hasToInvoiceByAddress,
c.email,
c.isToBeMailed,
a.id addressFk
FROM ticket t
LEFT JOIN sale s ON s.ticketFk = t.id
LEFT JOIN ticketService ts ON ts.ticketFk = t.id
JOIN address a ON a.id = t.addressFk
JOIN client c ON c.id = t.clientFk
WHERE ISNULL(t.refFk) AND c.id BETWEEN ? AND ?
AND t.shipped BETWEEN ? AND util.dayEnd(?)
AND t.companyFk = ? AND c.hasToInvoice
AND c.isTaxDataChecked
GROUP BY c.id, IF(c.hasToInvoiceByAddress,a.id,TRUE) HAVING sumAmount > 0`;
return models.InvoiceOut.rawSql(query, [
args.fromClientId,
args.toClientId,
minShipped,
args.maxShipped,
args.companyFk
], options);
}
};

View File

@ -7,4 +7,5 @@ module.exports = Self => {
require('../methods/invoiceOut/book')(Self); require('../methods/invoiceOut/book')(Self);
require('../methods/invoiceOut/createPdf')(Self); require('../methods/invoiceOut/createPdf')(Self);
require('../methods/invoiceOut/createManualInvoice')(Self); require('../methods/invoiceOut/createManualInvoice')(Self);
require('../methods/invoiceOut/globalInvoicing')(Self);
}; };

View File

@ -8,3 +8,4 @@ import './card';
import './descriptor'; import './descriptor';
import './descriptor-popover'; import './descriptor-popover';
import './index/manual'; import './index/manual';
import './index/global-invoicing';

View File

@ -0,0 +1,62 @@
<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>
<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-autocomplete
url="Clients"
label="From client"
search-function="{or: [{id: $search}, {name: {like: '%'+$search+'%'}}]}"
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+'%'}}]}"
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 response="accept" translate vn-focus>Make invoice</button>
</tpl-buttons>

View File

@ -0,0 +1,77 @@
import ngModule from '../../module';
import Dialog from 'core/components/dialog';
import './style.scss';
class Controller extends Dialog {
constructor($element, $, $transclude) {
super($element, $, $transclude);
this.invoice = {
maxShipped: new Date()
};
this.getMinClientId();
this.getMaxClientId();
}
getMinClientId() {
this.getClientId('min').then(res => {
this.invoice.fromClientId = res.data.id;
});
}
getMaxClientId() {
this.getClientId('max').then(res => {
this.invoice.toClientId = res.data.id;
});
}
getClientId(func) {
const order = func == 'min' ? 'ASC' : 'DESC';
const params = {
filter: {
order: 'id ' + order,
limit: 1
}
};
return this.$http.get('Clients/findOne', {params});
}
get companyFk() {
return this.invoice.companyFk;
}
set companyFk(value) {
this.invoice.companyFk = value;
}
responseHandler(response) {
try {
if (response !== 'accept')
return super.responseHandler(response);
/* if (this.invoice.clientFk && !this.invoice.maxShipped)
throw new Error('Client and the max shipped should be filled');
if (!this.invoice.serial || !this.invoice.taxArea)
throw new Error('Some fields are required'); */
return this.$http.post(`InvoiceOuts/globalInvoicing`, this.invoice)
.then(() => super.responseHandler(response))
.then(() => this.vnApp.showSuccess(this.$t('Data saved!')));
} catch (e) {
this.vnApp.showError(this.$t(e.message));
return false;
}
}
}
Controller.$inject = ['$element', '$scope', '$transclude'];
ngModule.vnComponent('vnInvoiceOutGlobalInvoicing', {
slotTemplate: require('./index.html'),
controller: Controller,
bindings: {
companyFk: '<?'
}
});

View File

@ -0,0 +1,66 @@
import './index';
describe('InvoiceOut', () => {
describe('Component vnInvoiceOutManual', () => {
let controller;
let $httpBackend;
beforeEach(ngModule('invoiceOut'));
beforeEach(inject(($componentController, $rootScope, _$httpBackend_) => {
$httpBackend = _$httpBackend_;
let $scope = $rootScope.$new();
const $element = angular.element('<vn-invoice-out-manual></vn-invoice-out-manual>');
const $transclude = {
$$boundTransclude: {
$$slots: []
}
};
controller = $componentController('vnInvoiceOutManual', {$element, $scope, $transclude});
}));
describe('responseHandler()', () => {
it('should throw an error when clientFk property is set and the maxShipped is not filled', () => {
jest.spyOn(controller.vnApp, 'showError');
controller.invoice = {
clientFk: 1101,
serial: 'T',
taxArea: 'B'
};
controller.responseHandler('accept');
expect(controller.vnApp.showError).toHaveBeenCalledWith(`Client and the max shipped should be filled`);
});
it('should throw an error when some required fields are not filled in', () => {
jest.spyOn(controller.vnApp, 'showError');
controller.invoice = {
ticketFk: 1101
};
controller.responseHandler('accept');
expect(controller.vnApp.showError).toHaveBeenCalledWith(`Some fields are required`);
});
it('should make an http POST query and then call to the parent showSuccess() method', () => {
jest.spyOn(controller.vnApp, 'showSuccess');
controller.invoice = {
ticketFk: 1101,
serial: 'T',
taxArea: 'B'
};
$httpBackend.expect('POST', `InvoiceOuts/createManualInvoice`).respond({id: 1});
controller.responseHandler('accept');
$httpBackend.flush();
expect(controller.vnApp.showSuccess).toHaveBeenCalled();
});
});
});
});

View File

@ -0,0 +1,3 @@
Create global invoice: Crear factura global
Some fields are required: Algunos campos son obligatorios
Max date: Fecha límite

View File

@ -0,0 +1,5 @@
.vn-invoice-out-global-invoicing {
tpl-body {
width: 500px
}
}

View File

@ -74,6 +74,11 @@
ng-click="manualInvoicing.show()"> ng-click="manualInvoicing.show()">
Manual invoicing Manual invoicing
</vn-item> </vn-item>
<vn-item translate
name="globalInvoice"
ng-click="globalInvoicing.show()">
Global invoicing
</vn-item>
</vn-menu> </vn-menu>
</vn-vertical> </vn-vertical>
</div> </div>
@ -88,3 +93,7 @@
<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

@ -4,3 +4,4 @@ Due date: Fecha vencimiento
Has PDF: PDF disponible Has PDF: PDF disponible
Minimum: Minimo Minimum: Minimo
Maximum: Máximo Maximum: Máximo
Global invoicing: Facturación global

View File

@ -100,13 +100,14 @@ module.exports = function(Self) {
}, myOptions); }, myOptions);
} }
if (serial != 'R' && invoiceId) { if (serial != 'R' && invoiceId)
await Self.rawSql('CALL invoiceOutBooking(?)', [invoiceId], myOptions); await Self.rawSql('CALL invoiceOutBooking(?)', [invoiceId], myOptions);
await models.InvoiceOut.createPdf(ctx, invoiceId, myOptions);
}
if (tx) await tx.commit(); if (tx) await tx.commit();
if (serial != 'R' && invoiceId)
await models.InvoiceOut.createPdf(ctx, invoiceId);
return {invoiceFk: invoiceId, serial: serial}; return {invoiceFk: invoiceId, serial: serial};
} catch (e) { } catch (e) {
if (tx) await tx.rollback(); if (tx) await tx.rollback();