diff --git a/db/export-structure.sh b/db/export-structure.sh index e2a5a618b..c5c65ef34 100755 --- a/db/export-structure.sh +++ b/db/export-structure.sh @@ -3,7 +3,6 @@ SCHEMAS=( account bs - bi cache edi hedera diff --git a/loopback/locale/es.json b/loopback/locale/es.json index 872d45ae0..ff30a61ff 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/loopback/server/datasources.json b/loopback/server/datasources.json index 87fff60e1..0df03882c 100644 --- a/loopback/server/datasources.json +++ b/loopback/server/datasources.json @@ -18,20 +18,15 @@ "acquireTimeout": 20000 }, "osticket": { - "connector": "vn-mysql", - "database": "vn", - "debug": false, - "host": "localhost", - "port": "3306", - "username": "root", - "password": "root" + "connector": "memory", + "timezone": "local" }, "tempStorage": { "name": "tempStorage", "connector": "loopback-component-storage", - "provider": "filesystem", + "provider": "filesystem", "root": "./storage/tmp", - "maxFileSize": "262144000", + "maxFileSize": "262144000", "allowedContentTypes": [ "application/x-7z-compressed", "application/x-zip-compressed", @@ -41,17 +36,17 @@ "application/zip", "application/rar", "multipart/x-zip", - "image/png", - "image/jpeg", + "image/png", + "image/jpeg", "image/jpg" ] }, "dmsStorage": { "name": "dmsStorage", "connector": "loopback-component-storage", - "provider": "filesystem", + "provider": "filesystem", "root": "./storage/dms", - "maxFileSize": "262144000", + "maxFileSize": "262144000", "allowedContentTypes": [ "application/x-7z-compressed", "application/x-zip-compressed", @@ -61,32 +56,32 @@ "application/zip", "application/rar", "multipart/x-zip", - "image/png", - "image/jpeg", + "image/png", + "image/jpeg", "image/jpg" ] }, "imageStorage": { "name": "imageStorage", "connector": "loopback-component-storage", - "provider": "filesystem", + "provider": "filesystem", "root": "./storage/image", - "maxFileSize": "52428800", + "maxFileSize": "52428800", "allowedContentTypes": [ - "image/png", - "image/jpeg", + "image/png", + "image/jpeg", "image/jpg" ] }, "invoiceStorage": { "name": "invoiceStorage", "connector": "loopback-component-storage", - "provider": "filesystem", + "provider": "filesystem", "root": "./storage/pdfs/invoice", - "maxFileSize": "52428800", + "maxFileSize": "52428800", "allowedContentTypes": [ "application/octet-stream", "application/pdf" ] } -} +} \ 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 1f695aba8..d8a126ed2 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 000000000..0ac1a3930 --- /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/client/front/balance/create/index.html b/modules/client/front/balance/create/index.html index 81be14382..357ae5d03 100644 --- a/modules/client/front/balance/create/index.html +++ b/modules/client/front/balance/create/index.html @@ -60,7 +60,8 @@ + label="Delivered amount" + step="0.01"> { accepts: [ { arg: 'filter', - type: 'Object', + type: 'object', description: 'Filter defining where, order, offset, and limit - must be a JSON-encoded string', http: {source: 'query'} }, { arg: 'search', - type: 'String', + type: 'string', description: 'Searchs the invoiceOut by id', http: {source: 'query'} }, { arg: 'clientFk', - type: 'Integer', + type: 'integer', description: 'The client id', http: {source: 'query'} }, { arg: 'fi', - type: 'String', + type: 'string', description: 'The client fiscal id', http: {source: 'query'} }, { arg: 'hasPdf', - type: 'Boolean', + type: 'boolean', description: 'Whether the the invoiceOut has PDF or not', http: {source: 'query'} }, { arg: 'amount', - type: 'Number', + type: 'number', description: 'The amount filter', http: {source: 'query'} }, { arg: 'min', - type: 'Number', + type: 'number', description: 'The minimun amount flter', http: {source: 'query'} }, { arg: 'max', - type: 'Number', + type: 'number', description: 'The maximun amount flter', http: {source: 'query'} }, { arg: 'issued', - type: 'Date', + type: 'date', description: 'The issued date filter', http: {source: 'query'} }, { arg: 'created', - type: 'Date', + type: 'date', description: 'The created date filter', http: {source: 'query'} }, { arg: 'dued', - type: 'Date', + type: 'date', description: 'The due date filter', http: {source: 'query'} } ], returns: { - type: ['Object'], + type: ['object'], root: true }, http: { diff --git a/modules/item/back/methods/item/filter.js b/modules/item/back/methods/item/filter.js index eba0b0f91..dca808aa3 100644 --- a/modules/item/back/methods/item/filter.js +++ b/modules/item/back/methods/item/filter.js @@ -10,48 +10,57 @@ module.exports = Self => { accepts: [ { arg: 'filter', - type: 'Object', + type: 'object', description: 'Filter defining where, order, offset, and limit - must be a JSON-encoded string', - }, { + }, + { arg: 'tags', - type: ['Object'], + type: ['object'], description: 'List of tags to filter with', - }, { + }, + { arg: 'search', - type: 'String', + type: 'string', description: `If it's and integer searchs by id, otherwise it searchs by name`, - }, { + }, + { arg: 'id', - type: 'Integer', + type: 'integer', description: 'Item id', - }, { + }, + { arg: 'categoryFk', - type: 'Integer', + type: 'integer', description: 'Category id', - }, { + }, + { arg: 'typeFk', - type: 'Integer', + type: 'integer', description: 'Type id', - }, { + }, + { arg: 'isActive', - type: 'Boolean', + type: 'boolean', description: 'Whether the the item is or not active', - }, { + }, + { arg: 'salesPersonFk', - type: 'Integer', + type: 'integer', description: 'The buyer of the item', - }, { + }, + { arg: 'description', - type: 'String', + type: 'string', description: 'The item description', - }, { + }, + { arg: 'stemMultiplier', - type: 'Integer', + type: 'integer', description: 'The item multiplier', } ], returns: { - type: ['Object'], + type: ['object'], root: true }, http: { @@ -60,23 +69,28 @@ module.exports = Self => { } }); - Self.filter = async(ctx, filter) => { - let conn = Self.dataSource.connector; + Self.filter = async(ctx, filter, options) => { + const conn = Self.dataSource.connector; + let myOptions = {}; + + if (typeof options == 'object') + Object.assign(myOptions, options); + let codeWhere; if (ctx.args.search) { - let items = await Self.app.models.ItemBarcode.find({ + const items = await Self.app.models.ItemBarcode.find({ where: {code: ctx.args.search}, fields: ['itemFk'] - }); - let itemIds = []; + }, myOptions); + const itemIds = []; for (const item of items) itemIds.push(item.itemFk); codeWhere = {'i.id': {inq: itemIds}}; } - let where = buildFilter(ctx.args, (param, value) => { + const where = buildFilter(ctx.args, (param, value) => { switch (param) { case 'search': return /^\d+$/.test(value) @@ -90,8 +104,8 @@ module.exports = Self => { return {'i.stemMultiplier': value}; case 'typeFk': return {'i.typeFk': value}; - case 'category': - return {'ic.name': value}; + case 'categoryFk': + return {'ic.id': value}; case 'salesPersonFk': return {'it.workerFk': value}; case 'origin': @@ -104,7 +118,7 @@ module.exports = Self => { }); filter = mergeFilters(filter, {where}); - let stmts = []; + const stmts = []; let stmt; stmt = new ParameterizedSQL( @@ -173,9 +187,10 @@ module.exports = Self => { stmt.merge(conn.makeWhere(filter.where)); stmt.merge(conn.makePagination(filter)); - let itemsIndex = stmts.push(stmt) - 1; - let sql = ParameterizedSQL.join(stmts, ';'); - let result = await conn.executeStmt(sql); + const itemsIndex = stmts.push(stmt) - 1; + const sql = ParameterizedSQL.join(stmts, ';'); + const result = await conn.executeStmt(sql, myOptions); + return itemsIndex === 0 ? result : result[itemsIndex]; }; }; diff --git a/modules/item/back/methods/item/specs/filter.spec.js b/modules/item/back/methods/item/specs/filter.spec.js index 72175ccc4..340bc0db2 100644 --- a/modules/item/back/methods/item/specs/filter.spec.js +++ b/modules/item/back/methods/item/specs/filter.spec.js @@ -2,29 +2,62 @@ const app = require('vn-loopback/server/server'); describe('item filter()', () => { it('should return 1 result filtering by id', async() => { - let filter = {}; - let result = await app.models.Item.filter({args: {filter: filter, search: 1}}); + const tx = await app.models.Item.beginTransaction({}); + const options = {transaction: tx}; - expect(result.length).toEqual(1); - expect(result[0].id).toEqual(1); + try { + const filter = {}; + const ctx = {args: {filter: filter, search: 1}}; + const result = await app.models.Item.filter(ctx, filter, options); + + expect(result.length).toEqual(1); + expect(result[0].id).toEqual(1); + + await tx.rollback(); + } catch (e) { + await tx.rollback(); + throw e; + } }); it('should return 1 result filtering by barcode', async() => { - let filter = {}; - let result = await app.models.Item.filter({args: {filter: filter, search: 4444444444}}); + const tx = await app.models.Item.beginTransaction({}); + const options = {transaction: tx}; - expect(result.length).toEqual(1); - expect(result[0].id).toEqual(2); + try { + const filter = {}; + const ctx = {args: {filter: filter, search: 4444444444}}; + const result = await app.models.Item.filter(ctx, filter, options); + + expect(result.length).toEqual(1); + expect(result[0].id).toEqual(2); + + await tx.rollback(); + } catch (e) { + await tx.rollback(); + throw e; + } }); it('should return 2 results using filter and tags', async() => { - let filter = { - order: 'isActive ASC, name', - limit: 8 - }; - let tags = [{value: 'medical box', tagFk: 58}]; - let result = await app.models.Item.filter({args: {filter: filter, typeFk: 5, tags: tags}}); + const tx = await app.models.Item.beginTransaction({}); + const options = {transaction: tx}; - expect(result.length).toEqual(2); + try { + const filter = { + order: 'isActive ASC, name', + limit: 8 + }; + const tags = [{value: 'medical box', tagFk: 58}]; + const ctx = {args: {filter: filter, typeFk: 5, tags: tags}}; + const result = await app.models.Item.filter(ctx, filter, options); + + expect(result.length).toEqual(2); + + await tx.rollback(); + } catch (e) { + await tx.rollback(); + throw e; + } }); }); diff --git a/modules/ticket/back/methods/ticket/canBeInvoiced.js b/modules/ticket/back/methods/ticket/canBeInvoiced.js index 8300ae110..facb7b945 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 a44c41e16..d8c2dc5c9 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 000000000..77da98b26 --- /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 32c769ca4..55c5bccd7 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 17ed36ab5..5da9544e2 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 931f0be0d..b102b1f44 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 79774c647..63b0b049d 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 9dc3d81b8..2df4de0a5 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 000000000..6b411b492 --- /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 9ff8d1568..eac0084f6 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 diff --git a/modules/worker/front/calendar/index.js b/modules/worker/front/calendar/index.js index d60fa0647..014a35b63 100644 --- a/modules/worker/front/calendar/index.js +++ b/modules/worker/front/calendar/index.js @@ -91,8 +91,10 @@ class Controller extends Section { } getActiveContract() { - this.$http.get(`Workers/${this.worker.id}/activeContract`) - .then(res => this.businessId = res.data.businessFk); + this.$http.get(`Workers/${this.worker.id}/activeContract`).then(res => { + if (res.data) + this.businessId = res.data.businessFk; + }); } getContractHolidays() { diff --git a/modules/worker/front/time-control/index.js b/modules/worker/front/time-control/index.js index b980243c9..2631c82d2 100644 --- a/modules/worker/front/time-control/index.js +++ b/modules/worker/front/time-control/index.js @@ -90,7 +90,10 @@ class Controller extends Section { getActiveContract() { return this.$http.get(`Workers/${this.worker.id}/activeContract`) - .then(res => this.businessId = res.data.businessFk); + .then(res => { + if (res.data) + this.businessId = res.data.businessFk; + }); } fetchHours() { @@ -111,6 +114,8 @@ class Controller extends Section { } getAbsences() { + if (!this.businessId) return; + const fullYear = this.started.getFullYear(); let params = { businessFk: this.businessId, diff --git a/print/templates/reports/driver-route/driver-route.html b/print/templates/reports/driver-route/driver-route.html index bad1bf179..eed85e1d7 100644 --- a/print/templates/reports/driver-route/driver-route.html +++ b/print/templates/reports/driver-route/driver-route.html @@ -1,163 +1,166 @@ - - - - - + + +
- - - -
-
-

{{$t('route')}} {{route.id}}

-
-
{{$t('information')}}
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
{{$t('route')}}{{route.id}}{{$t('driver')}}{{route.userNickName}}
{{$t('date')}}{{route.created | date('%d-%m-%Y')}}{{$t('vehicle')}}{{route.vehicleTradeMark}} {{route.vehicleModel}}
{{$t('time')}}{{route.time | date('%H:%M')}}{{route.plateNumber}}
{{$t('volume')}}{{route.m3}}{{$t('agency')}}{{route.agencyName}}
-
- - - - - - - - - - - - - - - -
-

Hora inicio

-
-

Hora fin

-
-

Km inicio

-
-

Km fin

-
-
- -
-
-
- -
-
-
-
-
-
- -
-
- - - - - - - - - - - - - - - - - - - -
{{$t('order')}}{{$t('ticket')}}{{$t('client')}}{{$t('address')}}{{$t('packages')}}
{{ticket.priority}}{{ticket.id}}{{ticket.clientFk}} {{ticket.addressName}} - {{ticket.addressFk.toString().substr(0, ticket.addressFk.toString().length - 3)}} - - {{ticket.addressFk.toString().substr(-3, 3)}} - - {{ticket.packages}}
-
+ + + + + - - -
+ + + +
+
+

{{$t('route')}} {{route.id}}

+
+
{{$t('information')}}
+
- - - - + + + + - - + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - +
{{$t('street')}}{{ticket.street}}{{$t('postcode')}}{{ticket.postalCode}}{{$t('route')}}{{route.id}}{{$t('driver')}}{{route.userNickName}}
{{$t('city')}}{{ticket.city}}{{$t('date')}}{{route.created | date('%d-%m-%Y')}}{{$t('vehicle')}}{{route.vehicleTradeMark}} {{route.vehicleModel}}
{{$t('time')}}{{route.time | date('%H:%M')}}{{route.plateNumber}}
{{$t('volume')}}{{route.m3}} {{$t('agency')}}{{ticket.ticketAgency}}
{{$t('mobile')}}{{ticket.mobile}}{{$t('phone')}}{{ticket.phone}}
{{$t('warehouse')}}{{ticket.warehouseName}}{{$t('salesPerson')}}{{ticket.salesPersonName}}
{{$t('import')}}{{ticket.import | currency('EUR', $i18n.locale)}}{{route.agencyName}}
-
-

{{ticket.description}}

-

{{$t('stowaway')}}: {{ticket.shipFk}}

+
+ + + + + + + + + + + + + + + +
+

Hora inicio

+
+

Hora fin

+
+

Km inicio

+
+

Km fin

+
+
+ +
+
+
+ +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + +
{{$t('order')}}{{$t('ticket')}}{{$t('client')}}{{$t('address')}}{{$t('packages')}}{{$t('packagingType')}}
{{ticket.priority}}{{ticket.id}}{{ticket.clientFk}} {{ticket.addressName}} + {{ticket.addressFk.toString().substr(0, + ticket.addressFk.toString().length - 3)}} + + {{ticket.addressFk.toString().substr(-3, 3)}} + + {{ticket.packages}}{{ticket.itemPackingTypes}}
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{$t('street')}}{{ticket.street}}{{$t('postcode')}}{{ticket.postalCode}}
{{$t('city')}}{{ticket.city}}{{$t('agency')}}{{ticket.ticketAgency}}
{{$t('mobile')}}{{ticket.mobile}}{{$t('phone')}}{{ticket.phone}}
{{$t('warehouse')}}{{ticket.warehouseName}}{{$t('salesPerson')}}{{ticket.salesPersonName}}
{{$t('import')}}{{ticket.import | currency('EUR', $i18n.locale)}}
+
+

{{ticket.description}}

+

{{$t('stowaway')}}: {{ticket.shipFk}}

+
+
+
- - - -
- +
+ + + +
+ \ No newline at end of file diff --git a/print/templates/reports/driver-route/driver-route.js b/print/templates/reports/driver-route/driver-route.js index 39b5d44e9..0b2638239 100755 --- a/print/templates/reports/driver-route/driver-route.js +++ b/print/templates/reports/driver-route/driver-route.js @@ -30,7 +30,7 @@ module.exports = { return this.rawSqlFromDef('routes', [routesId]); }, fetchTickets(routesId) { - return this.rawSqlFromDef('tickets', [routesId]); + return this.rawSqlFromDef('tickets', [routesId, routesId]); } }, components: { diff --git a/print/templates/reports/driver-route/locale/es.yml b/print/templates/reports/driver-route/locale/es.yml index 7b86f527f..4f0f3ac3c 100644 --- a/print/templates/reports/driver-route/locale/es.yml +++ b/print/templates/reports/driver-route/locale/es.yml @@ -10,6 +10,7 @@ order: Orden client: Cliente address: Consignatario packages: Bultos +packagingType: Encajado street: Dirección postcode: Código Postal city: Ciudad diff --git a/print/templates/reports/driver-route/sql/tickets.sql b/print/templates/reports/driver-route/sql/tickets.sql index 1bdaf31a5..8806a0473 100644 --- a/print/templates/reports/driver-route/sql/tickets.sql +++ b/print/templates/reports/driver-route/sql/tickets.sql @@ -18,8 +18,9 @@ SELECT am.name ticketAgency, tob.description, s.shipFk, - u.nickName salesPersonName -FROM route r + u.nickName salesPersonName, + ipkg.itemPackingTypes +FROM route r LEFT JOIN ticket t ON t.routeFk = r.id LEFT JOIN address a ON a.id = t.addressFk LEFT JOIN client c ON c.id = t.clientFk @@ -30,5 +31,15 @@ FROM route r LEFT JOIN warehouse wh ON wh.id = t.warehouseFk LEFT JOIN agencyMode am ON am.id = t.agencyModeFk LEFT JOIN stowaway s ON s.id = t.id -WHERE r.id IN(?) -ORDER BY t.priority, t.id \ No newline at end of file + LEFT JOIN ( + SELECT t.id AS ticketFk, + GROUP_CONCAT(DISTINCT(i.itemPackingTypeFk)) AS itemPackingTypes + FROM route r + JOIN ticket t ON t.routeFk = r.id + JOIN sale s ON s.ticketFk = t.id + JOIN item i ON i.id = s.itemFk + WHERE r.id IN (?) + GROUP BY t.id + ) ipkg ON ipkg.ticketFk = t.id +WHERE r.id IN (?) +ORDER BY t.priority, t.id; \ No newline at end of file diff --git a/print/templates/reports/receipt/sql/receipt.sql b/print/templates/reports/receipt/sql/receipt.sql index 9b3919495..b8f5a4112 100644 --- a/print/templates/reports/receipt/sql/receipt.sql +++ b/print/templates/reports/receipt/sql/receipt.sql @@ -1,9 +1,11 @@ SELECT r.id, r.amountPaid, - r.amountUnpaid, + cr.amount AS amountUnpaid, r.payed, r.companyFk FROM receipt r JOIN client c ON c.id = r.clientFk + JOIN vn.clientRisk cr ON cr.clientFk = c.id + AND cr.companyFk = r.companyFk WHERE r.id = ? \ No newline at end of file