2983 - Invoice tickets from ticket index #685

Merged
carlosjr merged 7 commits from 2983-ticket_invoice into dev 2021-07-01 12:57:59 +00:00
11 changed files with 375 additions and 109 deletions
Showing only changes of commit 7e836e570f - Show all commits

View File

@ -182,5 +182,6 @@
"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"
}

View File

@ -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;

View File

@ -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);
});
});

View File

@ -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);
};
};

View File

@ -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;
}
};

View File

@ -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);
});
});

View File

@ -1,18 +1,17 @@
const app = require('vn-loopback/server/server');
const LoopBackContext = require('loopback-context');
const models = app.models;
describe('ticket makeInvoice()', () => {
fdescribe('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(`One or more 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();
}
});
});

View File

@ -193,15 +193,10 @@
</vn-item>
<vn-item
ng-click="makeInvoiceConfirmation.show()"
ng-show="$ctrl.totalChecked > 1"
name="makeInvoice"
translate>
Multiple invoices
</vn-item>
<vn-item
ng-click="makeInvoiceConfirmation.show()"
name="makeInvoice"
translate>
Rectificative invoice
Multiple invoice
</vn-item>
</vn-menu>
</vn-vertical>
@ -261,6 +256,6 @@
<vn-confirm
vn-id="makeInvoiceConfirmation"
on-accept="$ctrl.makeInvoice()"
question="You are going to invoice this ticket"
message="Are you sure you want to invoice this ticket?">
question="{{$ctrl.confirmationMessage}}"
message="Invoice selected tickets">
</vn-confirm>

View File

@ -75,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;
@ -162,8 +170,8 @@ export default class Controller extends Section {
}
makeInvoice() {
const [ticket] = this.checked;
return this.$http.post(`Tickets/${ticket.id}/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')));
}

View File

@ -0,0 +1 @@
Are you sure to invoice tickets: Are you sure to invoice {{ticketsAmount}} tickets?

View File

@ -12,4 +12,8 @@ Remove all filters: Eliminar todos los filtros
Copy value: Copiar valor
No verified data: Sin datos comprobados
Component lack: Faltan componentes
Quick invoice: Factura rápida
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?