diff --git a/loopback/locale/es.json b/loopback/locale/es.json
index 872d45ae09..ff30a61ff4 100644
--- a/loopback/locale/es.json
+++ b/loopback/locale/es.json
@@ -182,5 +182,7 @@
"Client assignment has changed": "He cambiado el comercial ~*\"<{{previousWorkerName}}>\"*~ por *\"<{{currentWorkerName}}>\"* del cliente [{{clientName}} ({{clientId}})]({{{url}}})",
"None": "Ninguno",
"The contract was not active during the selected date": "El contrato no estaba activo durante la fecha seleccionada",
- "This document already exists on this ticket": "Este documento ya existe en el ticket"
+ "This document already exists on this ticket": "Este documento ya existe en el ticket",
+ "Some of the selected tickets are not billable": "Algunos de los tickets seleccionados no son facturables",
+ "You can't invoice tickets from multiple clients": "No puedes facturar tickets de multiples clientes"
}
\ No newline at end of file
diff --git a/modules/client/back/methods/client/canBeInvoiced.js b/modules/client/back/methods/client/canBeInvoiced.js
index 1f695aba81..d8a126ed20 100644
--- a/modules/client/back/methods/client/canBeInvoiced.js
+++ b/modules/client/back/methods/client/canBeInvoiced.js
@@ -5,7 +5,7 @@ module.exports = function(Self) {
accepts: [
{
arg: 'id',
- type: 'string',
+ type: 'number',
required: true,
description: 'Client id',
http: {source: 'path'}
@@ -22,8 +22,18 @@ module.exports = function(Self) {
}
});
- Self.canBeInvoiced = async id => {
- let client = await Self.app.models.Client.findById(id, {fields: ['id', 'isTaxDataChecked', 'hasToInvoice']});
+ Self.canBeInvoiced = async(id, options) => {
+ const models = Self.app.models;
+
+ let myOptions = {};
+
+ if (typeof options == 'object')
+ Object.assign(myOptions, options);
+
+ const client = await models.Client.findById(id, {
+ fields: ['id', 'isTaxDataChecked', 'hasToInvoice']
+ }, myOptions);
+
if (client.isTaxDataChecked && client.hasToInvoice)
return true;
diff --git a/modules/client/back/methods/client/specs/canBeInvoiced.spec.js b/modules/client/back/methods/client/specs/canBeInvoiced.spec.js
new file mode 100644
index 0000000000..0ac1a39302
--- /dev/null
+++ b/modules/client/back/methods/client/specs/canBeInvoiced.spec.js
@@ -0,0 +1,63 @@
+const app = require('vn-loopback/server/server');
+const LoopBackContext = require('loopback-context');
+
+describe('client canBeInvoiced()', () => {
+ const userId = 19;
+ const clientId = 1101;
+ const activeCtx = {
+ accessToken: {userId: userId}
+ };
+ const models = app.models;
+
+ beforeAll(async done => {
+ spyOn(LoopBackContext, 'getCurrentContext').and.returnValue({
+ active: activeCtx
+ });
+
+ done();
+ });
+
+ it('should return falsy for a client without the data checked', async() => {
+ const tx = await models.Ticket.beginTransaction({});
+
+ try {
+ const options = {transaction: tx};
+
+ const client = await models.Client.findById(clientId, null, options);
+ await client.updateAttribute('isTaxDataChecked', false, options);
+
+ const canBeInvoiced = await models.Client.canBeInvoiced(clientId, options);
+
+ expect(canBeInvoiced).toEqual(false);
+
+ await tx.rollback();
+ } catch (e) {
+ await tx.rollback();
+ }
+ });
+
+ it('should return falsy for a client with invoicing disabled', async() => {
+ const tx = await models.Ticket.beginTransaction({});
+
+ try {
+ const options = {transaction: tx};
+
+ const client = await models.Client.findById(clientId, null, options);
+ await client.updateAttribute('hasToInvoice', false, options);
+
+ const canBeInvoiced = await models.Client.canBeInvoiced(clientId, options);
+
+ expect(canBeInvoiced).toEqual(false);
+
+ await tx.rollback();
+ } catch (e) {
+ await tx.rollback();
+ }
+ });
+
+ it('should return truthy for an invoiceable client', async() => {
+ const canBeInvoiced = await models.Client.canBeInvoiced(clientId);
+
+ expect(canBeInvoiced).toEqual(true);
+ });
+});
diff --git a/modules/ticket/back/methods/ticket/canBeInvoiced.js b/modules/ticket/back/methods/ticket/canBeInvoiced.js
index 8300ae1106..facb7b945f 100644
--- a/modules/ticket/back/methods/ticket/canBeInvoiced.js
+++ b/modules/ticket/back/methods/ticket/canBeInvoiced.js
@@ -4,11 +4,10 @@ module.exports = function(Self) {
accessType: 'READ',
accepts: [
{
- arg: 'id',
- type: 'number',
- required: true,
- description: 'The ticket id',
- http: {source: 'path'}
+ arg: 'ticketsIds',
+ description: 'The tickets id',
+ type: ['number'],
+ required: true
}
],
returns: {
@@ -17,26 +16,44 @@ module.exports = function(Self) {
root: true
},
http: {
- path: `/:id/canBeInvoiced`,
+ path: `/canBeInvoiced`,
verb: 'get'
}
});
- Self.canBeInvoiced = async id => {
- let ticket = await Self.app.models.Ticket.findById(id, {
+ Self.canBeInvoiced = async(ticketsIds, options) => {
+ let myOptions = {};
+
+ if (typeof options == 'object')
+ Object.assign(myOptions, options);
+
+ const tickets = await Self.find({
+ where: {
+ id: {inq: ticketsIds}
+ },
fields: ['id', 'refFk', 'shipped', 'totalWithVat']
+ }, myOptions);
+
+ const query = `
+ SELECT vn.hasSomeNegativeBase(t.id) AS hasSomeNegativeBase
+ FROM ticket t
+ WHERE id IN(?)`;
+ const ticketBases = await Self.rawSql(query, [ticketsIds], myOptions);
+ const hasSomeNegativeBase = ticketBases.some(
+ ticketBases => ticketBases.hasSomeNegativeBase
+ );
+
+ const today = new Date();
+
+ const invalidTickets = tickets.some(ticket => {
+ const shipped = new Date(ticket.shipped);
+ const shippingInFuture = shipped.getTime() > today.getTime();
+ const isInvoiced = ticket.refFk;
+ const priceZero = ticket.totalWithVat == 0;
+
+ return isInvoiced || priceZero || shippingInFuture;
});
- let query = `SELECT vn.hasSomeNegativeBase(?) AS hasSomeNegativeBase`;
- let [result] = await Self.rawSql(query, [id]);
- let hasSomeNegativeBase = result.hasSomeNegativeBase;
-
- let today = new Date();
- let shipped = new Date(ticket.shipped);
-
- if (ticket.refFk || ticket.totalWithVat == 0 || shipped.getTime() > today.getTime() || hasSomeNegativeBase)
- return false;
-
- return true;
+ return !(invalidTickets || hasSomeNegativeBase);
};
};
diff --git a/modules/ticket/back/methods/ticket/makeInvoice.js b/modules/ticket/back/methods/ticket/makeInvoice.js
index a44c41e169..d8c2dc5c9f 100644
--- a/modules/ticket/back/methods/ticket/makeInvoice.js
+++ b/modules/ticket/back/methods/ticket/makeInvoice.js
@@ -2,15 +2,14 @@ const UserError = require('vn-loopback/util/user-error');
module.exports = function(Self) {
Self.remoteMethodCtx('makeInvoice', {
- description: 'Make out an invoice from a ticket id',
+ description: 'Make out an invoice from one or more tickets',
accessType: 'WRITE',
accepts: [
{
- arg: 'id',
- type: 'string',
- required: true,
- description: 'Ticket id',
- http: {source: 'path'}
+ arg: 'ticketsIds',
+ description: 'The tickets id',
+ type: ['number'],
+ required: true
}
],
returns: {
@@ -19,61 +18,98 @@ module.exports = function(Self) {
root: true
},
http: {
- path: `/:id/makeInvoice`,
+ path: `/makeInvoice`,
verb: 'POST'
}
});
- Self.makeInvoice = async(ctx, id) => {
+ Self.makeInvoice = async(ctx, ticketsIds, options) => {
const userId = ctx.req.accessToken.userId;
const models = Self.app.models;
- const tx = await Self.beginTransaction({});
+
+ let tx;
+ let myOptions = {};
+
+ if (typeof options == 'object')
+ Object.assign(myOptions, options);
+
+ if (!myOptions.transaction) {
+ tx = await Self.beginTransaction({});
+ myOptions.transaction = tx;
+ }
try {
- const options = {transaction: tx};
+ const tickets = await models.Ticket.find({
+ where: {
+ id: {inq: ticketsIds}
+ },
+ fields: ['id', 'clientFk', 'companyFk']
+ }, myOptions);
- const filter = {fields: ['id', 'clientFk', 'companyFk']};
- const ticket = await models.Ticket.findById(id, filter, options);
+ const [firstTicket] = tickets;
+ const clientId = firstTicket.clientFk;
+ const companyId = firstTicket.companyFk;
- const clientCanBeInvoiced = await models.Client.canBeInvoiced(ticket.clientFk);
+ const isSameClient = tickets.every(ticket => ticket.clientFk == clientId);
+ if (!isSameClient)
+ throw new UserError(`You can't invoice tickets from multiple clients`);
+
+ const clientCanBeInvoiced = await models.Client.canBeInvoiced(clientId, myOptions);
if (!clientCanBeInvoiced)
throw new UserError(`This client can't be invoiced`);
- const ticketCanBeInvoiced = await models.Ticket.canBeInvoiced(ticket.id);
+ const ticketCanBeInvoiced = await models.Ticket.canBeInvoiced(ticketsIds, myOptions);
if (!ticketCanBeInvoiced)
- throw new UserError(`This ticket can't be invoiced`);
+ throw new UserError(`Some of the selected tickets are not billable`);
const query = `SELECT vn.invoiceSerial(?, ?, ?) AS serial`;
- const [result] = await Self.rawSql(query, [ticket.clientFk, ticket.companyFk, 'R'], options);
+ const [result] = await Self.rawSql(query, [
+ clientId,
+ companyId,
+ 'R'
+ ], myOptions);
const serial = result.serial;
- await Self.rawSql('CALL invoiceFromTicket(?)', [id], options);
- await Self.rawSql('CALL invoiceOut_new(?, CURDATE(), null, @invoiceId)', [serial], options);
+ await Self.rawSql(`
+ DROP TEMPORARY TABLE IF EXISTS ticketToInvoice;
+ CREATE TEMPORARY TABLE ticketToInvoice
+ (PRIMARY KEY (id))
+ ENGINE = MEMORY
+ SELECT id FROM vn.ticket
+ WHERE id IN(?) AND refFk IS NULL
+ `, [ticketsIds], myOptions);
- const [resultInvoice] = await Self.rawSql('SELECT @invoiceId id', [], options);
+ await Self.rawSql('CALL invoiceOut_new(?, CURDATE(), null, @invoiceId)', [serial], myOptions);
+
+ const [resultInvoice] = await Self.rawSql('SELECT @invoiceId id', [], myOptions);
const invoiceId = resultInvoice.id;
- const ticketInvoice = await models.Ticket.findById(id, {fields: ['refFk']}, options);
+ for (let ticket of tickets) {
+ const ticketInvoice = await models.Ticket.findById(ticket.id, {
+ fields: ['refFk']
+ }, myOptions);
- await models.TicketLog.create({
- originFk: ticket.id,
- userFk: userId,
- action: 'insert',
- changedModel: 'Ticket',
- changedModelId: ticket.id,
- newInstance: ticketInvoice
- }, options);
+ await models.TicketLog.create({
+ originFk: ticket.id,
+ userFk: userId,
+ action: 'insert',
+ changedModel: 'Ticket',
+ changedModelId: ticket.id,
+ newInstance: ticketInvoice
+ }, myOptions);
+ }
if (serial != 'R' && invoiceId) {
- await Self.rawSql('CALL invoiceOutBooking(?)', [invoiceId], options);
- await models.InvoiceOut.createPdf(ctx, invoiceId, options);
+ await Self.rawSql('CALL invoiceOutBooking(?)', [invoiceId], myOptions);
+ await models.InvoiceOut.createPdf(ctx, invoiceId, myOptions);
}
- await tx.commit();
- return {invoiceFk: invoiceId, serial};
+ if (tx) await tx.commit();
+
+ return {invoiceFk: invoiceId, serial: serial};
} catch (e) {
- await tx.rollback();
+ if (tx) await tx.rollback();
throw e;
}
};
diff --git a/modules/ticket/back/methods/ticket/specs/canBeInvoiced.spec.js b/modules/ticket/back/methods/ticket/specs/canBeInvoiced.spec.js
new file mode 100644
index 0000000000..77da98b267
--- /dev/null
+++ b/modules/ticket/back/methods/ticket/specs/canBeInvoiced.spec.js
@@ -0,0 +1,86 @@
+const app = require('vn-loopback/server/server');
+const LoopBackContext = require('loopback-context');
+const models = app.models;
+
+describe('ticket canBeInvoiced()', () => {
+ const userId = 19;
+ const ticketId = 11;
+ const activeCtx = {
+ accessToken: {userId: userId}
+ };
+
+ beforeAll(async done => {
+ spyOn(LoopBackContext, 'getCurrentContext').and.returnValue({
+ active: activeCtx
+ });
+
+ done();
+ });
+
+ it('should return falsy for an already invoiced ticket', async() => {
+ const tx = await models.Ticket.beginTransaction({});
+
+ try {
+ const options = {transaction: tx};
+
+ const ticket = await models.Ticket.findById(ticketId, null, options);
+ await ticket.updateAttribute('refFk', 'T1234567', options);
+
+ const canBeInvoiced = await models.Ticket.canBeInvoiced([ticketId], options);
+
+ expect(canBeInvoiced).toEqual(false);
+
+ await tx.rollback();
+ } catch (e) {
+ await tx.rollback();
+ }
+ });
+
+ it('should return falsy for a ticket with a price of zero', async() => {
+ const tx = await models.Ticket.beginTransaction({});
+
+ try {
+ const options = {transaction: tx};
+
+ const ticket = await models.Ticket.findById(ticketId, null, options);
+ await ticket.updateAttribute('totalWithVat', 0, options);
+
+ const canBeInvoiced = await models.Ticket.canBeInvoiced([ticketId], options);
+
+ expect(canBeInvoiced).toEqual(false);
+
+ await tx.rollback();
+ } catch (e) {
+ await tx.rollback();
+ }
+ });
+
+ it('should return falsy for a ticket shipping in future', async() => {
+ const tx = await models.Ticket.beginTransaction({});
+
+ try {
+ const options = {transaction: tx};
+
+ const ticket = await models.Ticket.findById(ticketId, null, options);
+
+ const shipped = new Date();
+ shipped.setDate(shipped.getDate() + 1);
+
+ await ticket.updateAttribute('shipped', shipped, options);
+
+ const canBeInvoiced = await models.Ticket.canBeInvoiced([ticketId], options);
+
+ expect(canBeInvoiced).toEqual(false);
+
+ await tx.rollback();
+ } catch (e) {
+ await tx.rollback();
+ }
+ });
+
+ it('should return truthy for an invoiceable ticket', async() => {
+ const canBeInvoiced = await models.Ticket.canBeInvoiced([ticketId]);
+
+ expect(canBeInvoiced).toEqual(true);
+ });
+});
diff --git a/modules/ticket/back/methods/ticket/specs/makeInvoice.spec.js b/modules/ticket/back/methods/ticket/specs/makeInvoice.spec.js
index 32c769ca4c..55c5bccd73 100644
--- a/modules/ticket/back/methods/ticket/specs/makeInvoice.spec.js
+++ b/modules/ticket/back/methods/ticket/specs/makeInvoice.spec.js
@@ -1,18 +1,17 @@
const app = require('vn-loopback/server/server');
const LoopBackContext = require('loopback-context');
+const models = app.models;
describe('ticket makeInvoice()', () => {
const userId = 19;
+ const ticketId = 11;
+ const clientId = 1102;
const activeCtx = {
accessToken: {userId: userId},
headers: {origin: 'http://localhost:5000'},
};
const ctx = {req: activeCtx};
- let invoice;
- let ticketId = 11;
- const okState = 3;
-
beforeAll(async done => {
spyOn(LoopBackContext, 'getCurrentContext').and.returnValue({
active: activeCtx
@@ -21,47 +20,93 @@ describe('ticket makeInvoice()', () => {
done();
});
- afterAll(async done => {
- try {
- let ticket = await app.models.Ticket.findById(11);
- await ticket.updateAttributes({refFk: null});
-
- let ticketTrackings = await app.models.TicketTracking.find({
- where: {
- ticketFk: ticketId,
- stateFk: {neq: okState}
- },
- order: 'id DESC'
- });
-
- for (let state of ticketTrackings)
- await state.destroy();
-
- let invoiceOut = await app.models.InvoiceOut.findById(invoice.invoiceFk);
- await invoiceOut.destroy();
- } catch (error) {
- console.error(error);
- }
- done();
- });
-
- it('should invoice a ticket, then try again to fail', async() => {
- const invoiceOutModel = app.models.InvoiceOut;
+ it('should throw an error when invoicing tickets from multiple clients', async() => {
+ const invoiceOutModel = models.InvoiceOut;
spyOn(invoiceOutModel, 'createPdf');
- invoice = await app.models.Ticket.makeInvoice(ctx, ticketId);
-
- expect(invoice.invoiceFk).toBeDefined();
- expect(invoice.serial).toEqual('T');
+ const tx = await models.Ticket.beginTransaction({});
let error;
- await app.models.Ticket.makeInvoice(ctx, ticketId).catch(e => {
- error = e;
- }).finally(() => {
- expect(error.message).toEqual(`This ticket can't be invoiced`);
- });
+ try {
+ const options = {transaction: tx};
+ const otherClientTicketId = 16;
+ await models.Ticket.makeInvoice(ctx, [ticketId, otherClientTicketId], options);
- expect(error).toBeDefined();
+ await tx.rollback();
+ } catch (e) {
+ error = e;
+ await tx.rollback();
+ }
+
+ expect(error.message).toEqual(`You can't invoice tickets from multiple clients`);
+ });
+
+ it(`should throw an error when invoicing a client without tax data checked`, async() => {
+ const invoiceOutModel = models.InvoiceOut;
+ spyOn(invoiceOutModel, 'createPdf');
+
+ const tx = await models.Ticket.beginTransaction({});
+
+ let error;
+
+ try {
+ const options = {transaction: tx};
+
+ const client = await models.Client.findById(clientId, null, options);
+ await client.updateAttribute('isTaxDataChecked', false, options);
+
+ await models.Ticket.makeInvoice(ctx, [ticketId], options);
+
+ await tx.rollback();
+ } catch (e) {
+ error = e;
+ await tx.rollback();
+ }
+
+ expect(error.message).toEqual(`This client can't be invoiced`);
+ });
+
+ it('should invoice a ticket, then try again to fail', async() => {
+ const invoiceOutModel = models.InvoiceOut;
+ spyOn(invoiceOutModel, 'createPdf');
+
+ const tx = await models.Ticket.beginTransaction({});
+
+ let error;
+
+ try {
+ const options = {transaction: tx};
+
+ await models.Ticket.makeInvoice(ctx, [ticketId], options);
+ await models.Ticket.makeInvoice(ctx, [ticketId], options);
+
+ await tx.rollback();
+ } catch (e) {
+ error = e;
+ await tx.rollback();
+ }
+
+ expect(error.message).toEqual(`Some of the selected tickets are not billable`);
+ });
+
+ it('should success to invoice a ticket', async() => {
+ const invoiceOutModel = models.InvoiceOut;
+ spyOn(invoiceOutModel, 'createPdf');
+
+ const tx = await models.Ticket.beginTransaction({});
+
+ try {
+ const options = {transaction: tx};
+
+ const invoice = await models.Ticket.makeInvoice(ctx, [ticketId], options);
+
+ expect(invoice.invoiceFk).toBeDefined();
+ expect(invoice.serial).toEqual('T');
+
+ await tx.rollback();
+ } catch (e) {
+ await tx.rollback();
+ }
});
});
diff --git a/modules/ticket/front/descriptor-menu/index.js b/modules/ticket/front/descriptor-menu/index.js
index 17ed36ab57..5da9544e2b 100644
--- a/modules/ticket/front/descriptor-menu/index.js
+++ b/modules/ticket/front/descriptor-menu/index.js
@@ -217,7 +217,8 @@ class Controller extends Section {
}
makeInvoice() {
- return this.$http.post(`Tickets/${this.id}/makeInvoice`)
+ const params = {ticketsIds: [this.id]};
+ return this.$http.post(`Tickets/makeInvoice`, params)
.then(() => this.reload())
.then(() => this.vnApp.showSuccess(this.$t('Ticket invoiced')));
}
diff --git a/modules/ticket/front/descriptor-menu/index.spec.js b/modules/ticket/front/descriptor-menu/index.spec.js
index 931f0be0d5..b102b1f445 100644
--- a/modules/ticket/front/descriptor-menu/index.spec.js
+++ b/modules/ticket/front/descriptor-menu/index.spec.js
@@ -139,7 +139,8 @@ describe('Ticket Component vnTicketDescriptorMenu', () => {
jest.spyOn(controller, 'reload').mockReturnThis();
jest.spyOn(controller.vnApp, 'showSuccess');
- $httpBackend.expectPOST(`Tickets/${ticket.id}/makeInvoice`).respond();
+ const expectedParams = {ticketsIds: [ticket.id]};
+ $httpBackend.expectPOST(`Tickets/makeInvoice`, expectedParams).respond();
controller.makeInvoice();
$httpBackend.flush();
diff --git a/modules/ticket/front/index/index.html b/modules/ticket/front/index/index.html
index 79774c647d..63b0b049d1 100644
--- a/modules/ticket/front/index/index.html
+++ b/modules/ticket/front/index/index.html
@@ -166,6 +166,15 @@
vn-tooltip="Payment on account..."
tooltip-position="left">
+
+
-
\ No newline at end of file
+
+
+
+
+
\ No newline at end of file
diff --git a/modules/ticket/front/index/index.js b/modules/ticket/front/index/index.js
index 9dc3d81b82..2df4de0a50 100644
--- a/modules/ticket/front/index/index.js
+++ b/modules/ticket/front/index/index.js
@@ -8,6 +8,7 @@ export default class Controller extends Section {
super($element, $);
this.vnReport = vnReport;
}
+
setDelivered() {
const checkedTickets = this.checked;
let ids = [];
@@ -74,6 +75,14 @@ export default class Controller extends Section {
return this.checked.length;
}
+ get confirmationMessage() {
+ if (!this.$.model) return 0;
+
+ return this.$t(`Are you sure to invoice tickets`, {
+ ticketsAmount: this.totalChecked
+ });
+ }
+
onMoreOpen() {
let options = this.moreOptions.filter(o => o.always || this.isChecked);
this.$.moreButton.data = options;
@@ -159,6 +168,13 @@ export default class Controller extends Section {
}
return {};
}
+
+ makeInvoice() {
+ const ticketsIds = this.checked.map(ticket => ticket.id);
+ return this.$http.post(`Tickets/makeInvoice`, {ticketsIds})
+ .then(() => this.$.model.refresh())
+ .then(() => this.vnApp.showSuccess(this.$t('Ticket invoiced')));
+ }
}
Controller.$inject = ['$element', '$scope', 'vnReport'];
diff --git a/modules/ticket/front/index/locale/en.yml b/modules/ticket/front/index/locale/en.yml
new file mode 100644
index 0000000000..6b411b4928
--- /dev/null
+++ b/modules/ticket/front/index/locale/en.yml
@@ -0,0 +1 @@
+Are you sure to invoice tickets: Are you sure to invoice {{ticketsAmount}} tickets?
\ No newline at end of file
diff --git a/modules/ticket/front/index/locale/es.yml b/modules/ticket/front/index/locale/es.yml
index 9ff8d15684..eac0084f6c 100644
--- a/modules/ticket/front/index/locale/es.yml
+++ b/modules/ticket/front/index/locale/es.yml
@@ -11,4 +11,9 @@ Remove filter: Quitar filtro por selección
Remove all filters: Eliminar todos los filtros
Copy value: Copiar valor
No verified data: Sin datos comprobados
-Component lack: Faltan componentes
\ No newline at end of file
+Component lack: Faltan componentes
+Quick invoice: Factura rápida
+Multiple invoice: Factura múltiple
+Make invoice...: Crear factura...
+Invoice selected tickets: Facturar tickets seleccionados
+Are you sure to invoice tickets: ¿Seguro que quieres facturar {{ticketsAmount}} tickets?
\ No newline at end of file