diff --git a/db/changes/10340-summer/00-ACL.sql b/db/changes/10340-summer/00-ACL.sql index a9a5778e5..fd92b3c1c 100644 --- a/db/changes/10340-summer/00-ACL.sql +++ b/db/changes/10340-summer/00-ACL.sql @@ -2,3 +2,6 @@ DELETE FROM `salix`.`ACL` WHERE id = 189; DELETE FROM `salix`.`ACL` WHERE id = 188; UPDATE `salix`.`ACL` tdms SET tdms.accessType = '*' WHERE tdms.id = 165; +INSERT INTO `salix`.`ACL` (model, property, accessType, permission, principalType, principalId) + VALUES ('InvoiceOut', 'createManualInvoice', 'WRITE', 'ALLOW', 'ROLE', 'invoicing'); + diff --git a/db/changes/10340-summer/00-invoiceFromClient.sql b/db/changes/10340-summer/00-invoiceFromClient.sql new file mode 100644 index 000000000..d198e873d --- /dev/null +++ b/db/changes/10340-summer/00-invoiceFromClient.sql @@ -0,0 +1,21 @@ +drop procedure `vn`.`invoiceFromClient`; + +DELIMITER $$ +$$ +create + definer = root@`%` procedure `vn`.`invoiceFromClient`(IN vMaxTicketDate datetime, IN vClientFk INT, IN vCompanyFk INT) +BEGIN + DECLARE vMinTicketDate DATE DEFAULT TIMESTAMPADD(YEAR, -3, CURDATE()); + SET vMaxTicketDate = util.dayend(vMaxTicketDate); + + DROP TEMPORARY TABLE IF EXISTS `ticketToInvoice`; + CREATE TEMPORARY TABLE `ticketToInvoice` + (PRIMARY KEY (`id`)) + ENGINE = MEMORY + SELECT id FROM ticket t + WHERE t.clientFk = vClientFk + AND t.refFk IS NULL + AND t.companyFk = vCompanyFk + AND (t.shipped BETWEEN vMinTicketDate AND vMaxTicketDate); +END;;$$ +DELIMITER ; diff --git a/db/changes/10340-summer/00-invoiceOut_newFromClient.sql b/db/changes/10340-summer/00-invoiceOut_newFromClient.sql new file mode 100644 index 000000000..b4d4f916b --- /dev/null +++ b/db/changes/10340-summer/00-invoiceOut_newFromClient.sql @@ -0,0 +1,45 @@ +drop procedure `vn`.`invoiceOut_newFromClient`; + +DELIMITER $$ +$$ +create + definer = root@`%` procedure `vn`.`invoiceOut_newFromClient`(IN vClientFk int, IN vSerial char(2), IN vMaxShipped date, + IN vCompanyFk int, IN vTaxArea varchar(25), + IN vRef varchar(25), OUT vInvoiceId int) +BEGIN +/** + * Factura los tickets de un cliente hasta una fecha dada + * @param vClientFk Id del cliente a facturar + * @param vSerial Serie de factura + * @param vMaxShipped Fecha hasta la cual cogera tickets para facturar + * @param vCompanyFk Id de la empresa desde la que se factura + * @param vTaxArea Tipo de iva en relacion a la empresa y al cliente, NULL por defecto + * @param vRef Referencia de la factura en caso que se quiera forzar, NULL por defecto + * @return vInvoiceId factura + */ + + DECLARE vIsRefEditable BOOLEAN; + + IF vRef IS NOT NULL THEN + SELECT isRefEditable INTO vIsRefEditable + FROM invoiceOutSerial + WHERE code = vSerial; + + IF NOT vIsRefEditable THEN + CALL util.throw('serial non editable'); + END IF; + END IF; + + CALL invoiceFromClient(vMaxShipped, vClientFk, vCompanyFk); + CALL invoiceOut_new(vSerial, CURDATE(), vTaxArea, vInvoiceId); + + UPDATE invoiceOut + SET `ref` = vRef + WHERE id = vInvoiceId + AND vRef IS NOT NULL; + + IF vSerial <> 'R' AND NOT ISNULL(vInvoiceId) AND vInvoiceId <> 0 THEN + CALL invoiceOutBooking(vInvoiceId); + END IF; +END;;$$ +DELIMITER ; diff --git a/db/changes/10340-summer/00-invoiceOut_newFromTicket.sql b/db/changes/10340-summer/00-invoiceOut_newFromTicket.sql new file mode 100644 index 000000000..15be3d83c --- /dev/null +++ b/db/changes/10340-summer/00-invoiceOut_newFromTicket.sql @@ -0,0 +1,38 @@ +drop procedure `vn`.`invoiceOut_newFromTicket`; + +DELIMITER $$ +$$ +create + definer = root@`%` procedure `vn`.`invoiceOut_newFromTicket`(IN vTicketFk int, IN vSerial char(2), IN vTaxArea varchar(25), + IN vRef varchar(25), OUT vInvoiceId int) +BEGIN +/** + * Factura un ticket + * @param vTicketFk Id del ticket + * @param vSerial Serie de factura + * @param vTaxArea Area de la factura en caso de querer forzarlo, + * en la mayoria de los casos poner NULL + * @return vInvoiceId + */ + DECLARE vIsRefEditable BOOLEAN; + CALL invoiceFromTicket(vTicketFk); + CALL invoiceOut_new(vSerial, CURDATE(), vTaxArea, vInvoiceId); + + IF vRef IS NOT NULL THEN + SELECT isRefEditable INTO vIsRefEditable + FROM invoiceOutSerial + WHERE code = vSerial; + IF NOT vIsRefEditable THEN + CALL util.throw('serial non editable'); + END IF; + + UPDATE invoiceOut + SET `ref` = vRef + WHERE id = vInvoiceId; + END IF; + + IF vSerial <> 'R' AND NOT ISNULL(vInvoiceId) AND vInvoiceId <> 0 THEN + CALL invoiceOutBooking(vInvoiceId); + END IF; +END;;$$ +DELIMITER ; diff --git a/e2e/helpers/selectors.js b/e2e/helpers/selectors.js index a561a08cf..40b2ae68a 100644 --- a/e2e/helpers/selectors.js +++ b/e2e/helpers/selectors.js @@ -915,6 +915,15 @@ export default { invoiceOutIndex: { topbarSearch: 'vn-searchbar', searchResult: 'vn-invoice-out-index vn-card > vn-table > div > vn-tbody > a.vn-tr', + createInvoice: 'vn-invoice-out-index > div > vn-vertical > vn-button > button vn-icon[icon="add"]', + createManualInvoice: 'vn-item[name="manualInvoice"]', + manualInvoiceForm: '.vn-invoice-out-manual', + manualInvoiceTicket: 'vn-autocomplete[ng-model="$ctrl.invoice.ticketFk"]', + manualInvoiceClient: 'vn-autocomplete[ng-model="$ctrl.invoice.clientFk"]', + manualInvoiceSerial: 'vn-autocomplete[ng-model="$ctrl.invoice.serial"]', + manualInvoiceTaxArea: 'vn-autocomplete[ng-model="$ctrl.invoice.taxArea"]', + saveManualInvoice: 'button[response="accept"]' + }, invoiceOutDescriptor: { moreMenu: 'vn-invoice-out-descriptor vn-icon-button[icon=more_vert]', diff --git a/e2e/paths/09-invoice-out/03_manualInvoice.spec.js b/e2e/paths/09-invoice-out/03_manualInvoice.spec.js new file mode 100644 index 000000000..aa8cc7454 --- /dev/null +++ b/e2e/paths/09-invoice-out/03_manualInvoice.spec.js @@ -0,0 +1,65 @@ +import selectors from '../../helpers/selectors.js'; +import getBrowser from '../../helpers/puppeteer'; + +describe('InvoiceOut manual invoice path', () => { + let browser; + let page; + + beforeAll(async() => { + browser = await getBrowser(); + page = browser.page; + await page.loginAndModule('administrative', 'invoiceOut'); + }); + + afterAll(async() => { + await browser.close(); + }); + + it('should open the manual invoice form', async() => { + await page.waitToClick(selectors.invoiceOutIndex.createInvoice); + await page.waitToClick(selectors.invoiceOutIndex.createManualInvoice); + await page.waitForSelector(selectors.invoiceOutIndex.manualInvoiceForm); + }); + + it('should create an invoice from a ticket', async() => { + await page.autocompleteSearch(selectors.invoiceOutIndex.manualInvoiceTicket, '7'); + await page.autocompleteSearch(selectors.invoiceOutIndex.manualInvoiceSerial, 'Global nacional'); + await page.autocompleteSearch(selectors.invoiceOutIndex.manualInvoiceTaxArea, 'national'); + await page.waitToClick(selectors.invoiceOutIndex.saveManualInvoice); + const message = await page.waitForSnackbar(); + + expect(message.text).toContain('Data saved!'); + }); + + it(`should have been redirected to the created invoice summary`, async() => { + await page.waitForState('invoiceOut.card.summary'); + }); + + it(`should navigate back to the invoiceOut index`, async() => { + await page.waitToClick(selectors.globalItems.applicationsMenuButton); + await page.waitForSelector(selectors.globalItems.applicationsMenuVisible); + await page.waitToClick(selectors.globalItems.invoiceOutButton); + await page.waitForSelector(selectors.invoiceOutIndex.topbarSearch); + await page.waitForState('invoiceOut.index'); + }); + + it('should now open the manual invoice form', async() => { + await page.waitToClick(selectors.invoiceOutIndex.createInvoice); + await page.waitToClick(selectors.invoiceOutIndex.createManualInvoice); + await page.waitForSelector(selectors.invoiceOutIndex.manualInvoiceForm); + }); + + it('should create an invoice from a client', async() => { + await page.autocompleteSearch(selectors.invoiceOutIndex.manualInvoiceClient, 'Charles Xavier'); + await page.autocompleteSearch(selectors.invoiceOutIndex.manualInvoiceSerial, 'Global nacional'); + await page.autocompleteSearch(selectors.invoiceOutIndex.manualInvoiceTaxArea, 'national'); + await page.waitToClick(selectors.invoiceOutIndex.saveManualInvoice); + const message = await page.waitForSnackbar(); + + expect(message.text).toContain('Data saved!'); + }); + + it(`should have been redirected to the created invoice summary`, async() => { + await page.waitForState('invoiceOut.card.summary'); + }); +}); diff --git a/loopback/locale/en.json b/loopback/locale/en.json index d77b0c26d..b71603f35 100644 --- a/loopback/locale/en.json +++ b/loopback/locale/en.json @@ -109,5 +109,7 @@ "This document already exists on this ticket": "This document already exists on this ticket", "State": "State", "regular": "regular", - "reserved": "reserved" + "reserved": "reserved", + "A ticket with a negative base can't be invoiced": "A ticket with a negative base can't be invoiced", + "This client is not invoiceable": "This client is not invoiceable" } \ No newline at end of file diff --git a/loopback/locale/es.json b/loopback/locale/es.json index f301df8cc..53213adae 100644 --- a/loopback/locale/es.json +++ b/loopback/locale/es.json @@ -194,5 +194,13 @@ "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", "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" + "You can't invoice tickets from multiple clients": "No puedes facturar tickets de multiples clientes", + "This client is not invoiceable": "Este cliente no es facturable", + "serial non editable": "Esta serie no permite asignar la referencia", + "Max shipped required": "La fecha límite es requerida", + "Can't invoice to future": "No se puede facturar a futuro", + "Can't invoice to past": "No se puede facturar a pasado", + "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 a negative base can't be invoiced": "No se puede facturar un ticket con una base negativa" } \ No newline at end of file diff --git a/modules/invoiceOut/back/methods/invoiceOut/createManualInvoice.js b/modules/invoiceOut/back/methods/invoiceOut/createManualInvoice.js new file mode 100644 index 000000000..854c1e2f9 --- /dev/null +++ b/modules/invoiceOut/back/methods/invoiceOut/createManualInvoice.js @@ -0,0 +1,182 @@ +const UserError = require('vn-loopback/util/user-error'); + +module.exports = Self => { + Self.remoteMethodCtx('createManualInvoice', { + description: 'Make a manual invoice', + accessType: 'WRITE', + accepts: [ + { + arg: 'clientFk', + type: 'any', + description: 'The invoiceable client id' + }, + { + arg: 'ticketFk', + type: 'any', + description: 'The invoiceable ticket id' + }, + { + arg: 'maxShipped', + type: 'date', + description: 'The maximum shipped date' + }, + { + arg: 'serial', + type: 'string', + description: 'The invoice serial' + }, + { + arg: 'taxArea', + type: 'string', + description: 'The invoice tax area' + }, + { + arg: 'reference', + type: 'string', + description: 'The invoice reference' + } + ], + returns: { + type: 'object', + root: true + }, + http: { + path: '/createManualInvoice', + verb: 'POST' + } + }); + + Self.createManualInvoice = async(ctx, options) => { + const models = Self.app.models; + const args = ctx.args; + + let tx; + const myOptions = {}; + + if (typeof options == 'object') + Object.assign(myOptions, options); + + if (!myOptions.transaction) { + tx = await Self.beginTransaction({}); + myOptions.transaction = tx; + } + + const ticketId = args.ticketFk; + let clientId = args.clientFk; + let maxShipped = args.maxShipped; + let companyId; + let query; + try { + if (ticketId) { + const ticket = await models.Ticket.findById(ticketId, null, myOptions); + const company = await models.Company.findById(ticket.companyFk, null, myOptions); + + clientId = ticket.clientFk; + maxShipped = ticket.shipped; + companyId = ticket.companyFk; + + // Validates invoiced ticket + if (ticket.refFk) + throw new UserError('This ticket is already invoiced'); + + // Validates ticket amount + if (ticket.totalWithVat == 0) + throw new UserError(`A ticket with an amount of zero can't be invoiced`); + + // Validates ticket nagative base + const hasNegativeBase = await getNegativeBase(ticketId, myOptions); + if (hasNegativeBase && company.code == 'VNL') + throw new UserError(`A ticket with a negative base can't be invoiced`); + } else { + if (!maxShipped) + throw new UserError(`Max shipped required`); + + const company = await models.Ticket.findOne({ + fields: ['companyFk'], + where: { + clientFk: clientId, + shipped: {lte: maxShipped} + } + }, myOptions); + companyId = company.companyFk; + } + + // Validate invoiceable client + const isClientInvoiceable = await isInvoiceable(clientId, myOptions); + if (!isClientInvoiceable) + throw new UserError(`This client is not invoiceable`); + + // Can't invoice tickets into future + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + if (maxShipped >= tomorrow) + throw new UserError(`Can't invoice to future`); + + const maxInvoiceDate = await getMaxIssued(args.serial, companyId, myOptions); + if (new Date() < maxInvoiceDate) + throw new UserError(`Can't invoice to past`); + + if (ticketId) { + query = `CALL invoiceOut_newFromTicket(?, ?, ?, ?, @newInvoiceId)`; + await Self.rawSql(query, [ + ticketId, + args.serial, + args.taxArea, + args.reference + ], myOptions); + } else { + query = `CALL invoiceOut_newFromClient(?, ?, ?, ?, ?, ?, @newInvoiceId)`; + await Self.rawSql(query, [ + clientId, + args.serial, + maxShipped, + companyId, + args.taxArea, + args.reference + ], myOptions); + } + + const [newInvoice] = await Self.rawSql(`SELECT @newInvoiceId id`, null, myOptions); + if (newInvoice.id) + await Self.createPdf(ctx, newInvoice.id, myOptions); + + if (tx) await tx.commit(); + + return newInvoice; + } catch (e) { + if (tx) await tx.rollback(); + throw e; + } + }; + + async function isInvoiceable(clientId, options) { + const models = Self.app.models; + const query = `SELECT (hasToInvoice AND isTaxDataChecked) AS invoiceable + FROM client + WHERE id = ?`; + const [result] = await models.InvoiceOut.rawSql(query, [clientId], options); + + return result.invoiceable; + } + + async function getNegativeBase(ticketId, options) { + const models = Self.app.models; + const query = 'SELECT vn.hasSomeNegativeBase(?) AS base'; + const [result] = await models.InvoiceOut.rawSql(query, [ticketId], options); + + return result.base; + } + + async function getMaxIssued(serial, companyId, options) { + const models = Self.app.models; + const query = `SELECT MAX(issued) AS issued + FROM invoiceOut + WHERE serial = ? AND companyFk = ?`; + const [maxIssued] = await models.InvoiceOut.rawSql(query, + [serial, companyId], options); + const maxInvoiceDate = maxIssued && maxIssued.issued || new Date(); + + return maxInvoiceDate; + } +}; diff --git a/modules/invoiceOut/back/methods/invoiceOut/createPdf.js b/modules/invoiceOut/back/methods/invoiceOut/createPdf.js index 6e596db62..5f43e4a32 100644 --- a/modules/invoiceOut/back/methods/invoiceOut/createPdf.js +++ b/modules/invoiceOut/back/methods/invoiceOut/createPdf.js @@ -58,11 +58,18 @@ module.exports = Self => { } }); - const invoiceYear = invoiceOut.created.getFullYear().toString(); - const container = await models.InvoiceContainer.container(invoiceYear); + const created = invoiceOut.created; + const year = created.getFullYear().toString(); + const month = created.getMonth().toString(); + const day = created.getDate().toString(); + + const container = await models.InvoiceContainer.container(year); const rootPath = container.client.root; const fileName = `${invoiceOut.ref}.pdf`; - fileSrc = path.join(rootPath, invoiceYear, fileName); + const src = path.join(rootPath, year, month, day); + fileSrc = path.join(src, fileName); + + await fs.mkdir(src, {recursive: true}); const writeStream = fs.createWriteStream(fileSrc); writeStream.on('open', () => { diff --git a/modules/invoiceOut/back/methods/invoiceOut/delete.js b/modules/invoiceOut/back/methods/invoiceOut/delete.js index 96b5e652e..d8b9d309b 100644 --- a/modules/invoiceOut/back/methods/invoiceOut/delete.js +++ b/modules/invoiceOut/back/methods/invoiceOut/delete.js @@ -34,13 +34,14 @@ module.exports = Self => { try { const invoiceOut = await Self.findById(id, {}, myOptions); - const tickets = await Self.app.models.Ticket.find({where: {refFk: invoiceOut.ref}}, myOptions); + const tickets = await Self.app.models.Ticket.find({ + where: {refFk: invoiceOut.ref} + }, myOptions); const promises = []; - tickets.forEach(ticket => { + for (let ticket of tickets) promises.push(ticket.updateAttribute('refFk', null, myOptions)); - }); await Promise.all(promises); diff --git a/modules/invoiceOut/back/methods/invoiceOut/download.js b/modules/invoiceOut/back/methods/invoiceOut/download.js index 3c9924fc3..983647982 100644 --- a/modules/invoiceOut/back/methods/invoiceOut/download.js +++ b/modules/invoiceOut/back/methods/invoiceOut/download.js @@ -1,4 +1,5 @@ const fs = require('fs-extra'); +const path = require('path'); module.exports = Self => { Self.remoteMethod('download', { @@ -33,24 +34,31 @@ module.exports = Self => { } }); - Self.download = async function(id) { - let file; - let env = process.env.NODE_ENV; - let [invoice] = await Self.rawSql(`SELECT invoiceOut_getPath(?) path`, [id]); + Self.download = async function(id, options) { + const models = Self.app.models; + const myOptions = {}; - if (env && env != 'development') { - file = { - path: `/var/lib/salix/pdfs/${invoice.path}`, - contentType: 'application/pdf', - name: `${id}.pdf` - }; - } else { - file = { - path: `${process.cwd()}/README.md`, - contentType: 'text/plain', - name: `README.md` - }; - } + if (typeof options == 'object') + Object.assign(myOptions, options); + + const invoiceOut = await models.InvoiceOut.findById(id, null, myOptions); + + const created = invoiceOut.created; + const year = created.getFullYear().toString(); + const month = created.getMonth().toString(); + const day = created.getDate().toString(); + + const container = await models.InvoiceContainer.container(year); + const rootPath = container.client.root; + const src = path.join(rootPath, year, month, day); + const fileName = `${invoiceOut.ref}.pdf`; + const fileSrc = path.join(src, fileName); + + const file = { + path: fileSrc, + contentType: 'application/pdf', + name: `${id}.pdf` + }; await fs.access(file.path); let stream = fs.createReadStream(file.path); diff --git a/modules/invoiceOut/back/methods/invoiceOut/specs/createManualInvoice.spec.js b/modules/invoiceOut/back/methods/invoiceOut/specs/createManualInvoice.spec.js new file mode 100644 index 000000000..e5c3c0512 --- /dev/null +++ b/modules/invoiceOut/back/methods/invoiceOut/specs/createManualInvoice.spec.js @@ -0,0 +1,145 @@ +const models = require('vn-loopback/server/server').models; +const LoopBackContext = require('loopback-context'); + +describe('InvoiceOut createManualInvoice()', () => { + const userId = 1; + const ticketId = 16; + const clientId = 1106; + const activeCtx = { + accessToken: {userId: userId}, + }; + const ctx = {req: activeCtx}; + + it('should throw an error trying to invoice again', async() => { + spyOn(models.InvoiceOut, 'createPdf').and.returnValue(true); + + const tx = await models.InvoiceOut.beginTransaction({}); + const options = {transaction: tx}; + + let error; + try { + ctx.args = { + ticketFk: ticketId, + serial: 'T', + taxArea: 'CEE' + }; + await models.InvoiceOut.createManualInvoice(ctx, options); + await models.InvoiceOut.createManualInvoice(ctx, options); + + await tx.rollback(); + } catch (e) { + error = e; + await tx.rollback(); + } + + expect(error.message).toContain('This ticket is already invoiced'); + }); + + it('should throw an error for a ticket with an amount of zero', async() => { + spyOn(models.InvoiceOut, 'createPdf').and.returnValue(true); + spyOn(LoopBackContext, 'getCurrentContext').and.returnValue({ + active: activeCtx + }); + + const tx = await models.InvoiceOut.beginTransaction({}); + const options = {transaction: tx}; + + let error; + try { + const ticket = await models.Ticket.findById(ticketId, null, options); + await ticket.updateAttributes({ + totalWithVat: 0 + }, options); + + ctx.args = { + ticketFk: ticketId, + serial: 'T', + taxArea: 'CEE' + }; + await models.InvoiceOut.createManualInvoice(ctx, options); + + await tx.rollback(); + } catch (e) { + error = e; + await tx.rollback(); + } + + expect(error.message).toContain(`A ticket with an amount of zero can't be invoiced`); + }); + + it('should throw an error when the clientFk property is set without the max shipped date', async() => { + spyOn(models.InvoiceOut, 'createPdf').and.returnValue(true); + + const tx = await models.InvoiceOut.beginTransaction({}); + const options = {transaction: tx}; + + let error; + try { + ctx.args = { + clientFk: clientId, + serial: 'T', + taxArea: 'CEE' + }; + await models.InvoiceOut.createManualInvoice(ctx, options); + + await tx.rollback(); + } catch (e) { + error = e; + await tx.rollback(); + } + + expect(error.message).toContain(`Max shipped required`); + }); + + it('should throw an error for a non-invoiceable client', async() => { + spyOn(models.InvoiceOut, 'createPdf').and.returnValue(true); + + const tx = await models.InvoiceOut.beginTransaction({}); + const options = {transaction: tx}; + + let error; + try { + const client = await models.Client.findById(clientId, null, options); + await client.updateAttributes({ + isTaxDataChecked: false + }, options); + + ctx.args = { + ticketFk: ticketId, + serial: 'T', + taxArea: 'CEE' + }; + await models.InvoiceOut.createManualInvoice(ctx, options); + + await tx.rollback(); + } catch (e) { + error = e; + await tx.rollback(); + } + + expect(error.message).toContain(`This client is not invoiceable`); + }); + + it('should create a manual invoice', async() => { + spyOn(models.InvoiceOut, 'createPdf').and.returnValue(true); + + const tx = await models.InvoiceOut.beginTransaction({}); + const options = {transaction: tx}; + + try { + ctx.args = { + ticketFk: ticketId, + serial: 'T', + taxArea: 'CEE' + }; + const result = await models.InvoiceOut.createManualInvoice(ctx, options); + + expect(result.id).toEqual(jasmine.any(Number)); + + await tx.rollback(); + } catch (e) { + await tx.rollback(); + throw e; + } + }); +}); diff --git a/modules/invoiceOut/back/methods/invoiceOut/specs/createPdf.spec.js b/modules/invoiceOut/back/methods/invoiceOut/specs/createPdf.spec.js index 60dd5576d..0ed0b35eb 100644 --- a/modules/invoiceOut/back/methods/invoiceOut/specs/createPdf.spec.js +++ b/modules/invoiceOut/back/methods/invoiceOut/specs/createPdf.spec.js @@ -19,8 +19,18 @@ describe('InvoiceOut createPdf()', () => { }; spyOn(got, 'stream').and.returnValue(response); - const result = await models.InvoiceOut.createPdf(ctx, invoiceId); + const tx = await models.InvoiceOut.beginTransaction({}); + const options = {transaction: tx}; - expect(result.hasPdf).toBe(true); + try { + const result = await models.InvoiceOut.createPdf(ctx, invoiceId, options); + + expect(result.hasPdf).toBe(true); + + await tx.rollback(); + } catch (e) { + await tx.rollback(); + throw e; + } }); }); diff --git a/modules/invoiceOut/back/methods/invoiceOut/specs/download.spec.js b/modules/invoiceOut/back/methods/invoiceOut/specs/download.spec.js index 2d9056708..3ad4c2f11 100644 --- a/modules/invoiceOut/back/methods/invoiceOut/specs/download.spec.js +++ b/modules/invoiceOut/back/methods/invoiceOut/specs/download.spec.js @@ -4,7 +4,7 @@ describe('InvoiceOut download()', () => { it('should return the downloaded fine name', async() => { const result = await models.InvoiceOut.download(1); - expect(result[1]).toEqual('text/plain'); - expect(result[2]).toEqual('filename="README.md"'); + expect(result[1]).toEqual('application/pdf'); + expect(result[2]).toEqual('filename="1.pdf"'); }); }); diff --git a/modules/invoiceOut/back/model-config.json b/modules/invoiceOut/back/model-config.json index e144ce80e..d52f79477 100644 --- a/modules/invoiceOut/back/model-config.json +++ b/modules/invoiceOut/back/model-config.json @@ -2,7 +2,25 @@ "InvoiceOut": { "dataSource": "vn" }, + "InvoiceOutSerial": { + "dataSource": "vn" + }, "InvoiceContainer": { "dataSource": "invoiceStorage" + }, + "TaxArea": { + "dataSource": "vn" + }, + "TaxClass": { + "dataSource": "vn" + }, + "TaxClassCode": { + "dataSource": "vn" + }, + "TaxCode": { + "dataSource": "vn" + }, + "TaxType": { + "dataSource": "vn" } } diff --git a/modules/invoiceOut/back/models/invoice-out-serial.json b/modules/invoiceOut/back/models/invoice-out-serial.json new file mode 100644 index 000000000..912269fd7 --- /dev/null +++ b/modules/invoiceOut/back/models/invoice-out-serial.json @@ -0,0 +1,38 @@ +{ + "name": "InvoiceOutSerial", + "base": "VnModel", + "options": { + "mysql": { + "table": "invoiceOutSerial" + } + }, + "properties": { + "code": { + "type": "string", + "id": true, + "description": "Identifier" + }, + "description": { + "type": "string" + }, + "isTaxed": { + "type": "boolean" + }, + "isCEE": { + "type": "boolean" + } + }, + "relations": { + "taxArea": { + "type": "belongsTo", + "model": "TaxArea", + "foreignKey": "taxAreaFk" + } + }, + "acls": [{ + "accessType": "READ", + "principalType": "ROLE", + "principalId": "$everyone", + "permission": "ALLOW" + }] +} \ No newline at end of file diff --git a/modules/invoiceOut/back/models/invoiceOut.js b/modules/invoiceOut/back/models/invoice-out.js similarity index 86% rename from modules/invoiceOut/back/models/invoiceOut.js rename to modules/invoiceOut/back/models/invoice-out.js index 7c6503d8e..8a1dda41f 100644 --- a/modules/invoiceOut/back/models/invoiceOut.js +++ b/modules/invoiceOut/back/models/invoice-out.js @@ -6,4 +6,5 @@ module.exports = Self => { require('../methods/invoiceOut/delete')(Self); require('../methods/invoiceOut/book')(Self); require('../methods/invoiceOut/createPdf')(Self); + require('../methods/invoiceOut/createManualInvoice')(Self); }; diff --git a/modules/invoiceOut/back/models/invoiceOut.json b/modules/invoiceOut/back/models/invoice-out.json similarity index 100% rename from modules/invoiceOut/back/models/invoiceOut.json rename to modules/invoiceOut/back/models/invoice-out.json diff --git a/modules/invoiceOut/back/models/tax-area.json b/modules/invoiceOut/back/models/tax-area.json new file mode 100644 index 000000000..0aa00d194 --- /dev/null +++ b/modules/invoiceOut/back/models/tax-area.json @@ -0,0 +1,22 @@ +{ + "name": "TaxArea", + "base": "VnModel", + "options": { + "mysql": { + "table": "taxArea" + } + }, + "properties": { + "code": { + "type": "string", + "id": true, + "description": "Identifier" + } + }, + "acls": [{ + "accessType": "READ", + "principalType": "ROLE", + "principalId": "$everyone", + "permission": "ALLOW" + }] +} \ No newline at end of file diff --git a/modules/item/back/models/tax-class-code.json b/modules/invoiceOut/back/models/tax-class-code.json similarity index 100% rename from modules/item/back/models/tax-class-code.json rename to modules/invoiceOut/back/models/tax-class-code.json diff --git a/modules/item/back/models/tax-class.json b/modules/invoiceOut/back/models/tax-class.json similarity index 100% rename from modules/item/back/models/tax-class.json rename to modules/invoiceOut/back/models/tax-class.json diff --git a/modules/item/back/models/tax-code.json b/modules/invoiceOut/back/models/tax-code.json similarity index 100% rename from modules/item/back/models/tax-code.json rename to modules/invoiceOut/back/models/tax-code.json diff --git a/modules/item/back/models/tax-type.json b/modules/invoiceOut/back/models/tax-type.json similarity index 100% rename from modules/item/back/models/tax-type.json rename to modules/invoiceOut/back/models/tax-type.json diff --git a/modules/invoiceOut/front/index.js b/modules/invoiceOut/front/index.js index 9843e188b..bdb87f9a9 100644 --- a/modules/invoiceOut/front/index.js +++ b/modules/invoiceOut/front/index.js @@ -7,3 +7,4 @@ import './summary'; import './card'; import './descriptor'; import './descriptor-popover'; +import './index/manual'; diff --git a/modules/invoiceOut/front/index/index.html b/modules/invoiceOut/front/index/index.html index 8685990a4..2ebd7b421 100644 --- a/modules/invoiceOut/front/index/index.html +++ b/modules/invoiceOut/front/index/index.html @@ -57,6 +57,26 @@ +
+ + + + + + + Manual invoicing + + + +
@@ -65,3 +85,6 @@ + + \ No newline at end of file diff --git a/modules/invoiceOut/front/index/locale/es.yml b/modules/invoiceOut/front/index/locale/es.yml index eb22e1779..b166cca8f 100644 --- a/modules/invoiceOut/front/index/locale/es.yml +++ b/modules/invoiceOut/front/index/locale/es.yml @@ -3,4 +3,5 @@ Issued: Fecha factura Due date: Fecha vencimiento Has PDF: PDF disponible Minimum: Minimo -Maximum: Máximo \ No newline at end of file +Maximum: Máximo +Manual invoicing: Facturación manual \ No newline at end of file diff --git a/modules/invoiceOut/front/index/manual/index.html b/modules/invoiceOut/front/index/manual/index.html new file mode 100644 index 000000000..f2fd10d6f --- /dev/null +++ b/modules/invoiceOut/front/index/manual/index.html @@ -0,0 +1,75 @@ + + Create manual invoice + + + + + + + + + + {{::id}} - {{::nickname}} + + + Or + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/modules/invoiceOut/front/index/manual/index.js b/modules/invoiceOut/front/index/manual/index.js new file mode 100644 index 000000000..77dd93de7 --- /dev/null +++ b/modules/invoiceOut/front/index/manual/index.js @@ -0,0 +1,47 @@ +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() + }; + } + + 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/createManualInvoice`, this.invoice) + .then(res => { + this.$state.go('invoiceOut.card.summary', {id: res.data.id}); + 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('vnInvoiceOutManual', { + slotTemplate: require('./index.html'), + controller: Controller, + bindings: { + ticketFk: ' { + 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(''); + 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(); + }); + }); + }); +}); diff --git a/modules/invoiceOut/front/index/manual/locale/es.yml b/modules/invoiceOut/front/index/manual/locale/es.yml new file mode 100644 index 000000000..826057c8d --- /dev/null +++ b/modules/invoiceOut/front/index/manual/locale/es.yml @@ -0,0 +1,5 @@ +Create manual invoice: Crear factura manual +Some fields are required: Algunos campos son obligatorios +Client and max shipped fields should be filled: Los campos de cliente y fecha límite deben rellenarse +Max date: Fecha límite +Serial: Serie \ No newline at end of file diff --git a/modules/invoiceOut/front/index/manual/style.scss b/modules/invoiceOut/front/index/manual/style.scss new file mode 100644 index 000000000..998472157 --- /dev/null +++ b/modules/invoiceOut/front/index/manual/style.scss @@ -0,0 +1,5 @@ +.vn-invoice-out-manual { + tpl-body { + width: 500px + } +} \ No newline at end of file diff --git a/modules/item/back/model-config.json b/modules/item/back/model-config.json index 9f101f9c7..ab680b696 100644 --- a/modules/item/back/model-config.json +++ b/modules/item/back/model-config.json @@ -65,18 +65,6 @@ "Tag": { "dataSource": "vn" }, - "TaxClass": { - "dataSource": "vn" - }, - "TaxClassCode": { - "dataSource": "vn" - }, - "TaxCode": { - "dataSource": "vn" - }, - "TaxType": { - "dataSource": "vn" - }, "FixedPrice": { "dataSource": "vn" }