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/loopback/locale/en.json b/loopback/locale/en.json index 75804ba21..2484990ed 100644 --- a/loopback/locale/en.json +++ b/loopback/locale/en.json @@ -98,6 +98,6 @@ "Client assignment has changed": "I did change the salesperson ~*\"<{{previousWorkerName}}>\"*~ by *\"<{{currentWorkerName}}>\"* from the client [{{clientName}} ({{clientId}})]({{{url}}})", "None": "None", "error densidad = 0": "error densidad = 0", - "nickname": "nickname", - "This document already exists on this ticket": "This document already exists on this ticket" + "This document already exists on this ticket": "This document already exists on this ticket", + "serial non editable": "This serial doesn't allow to set a reference" } \ No newline at end of file diff --git a/loopback/locale/es.json b/loopback/locale/es.json index ff30a61ff..1d73cbbf3 100644 --- a/loopback/locale/es.json +++ b/loopback/locale/es.json @@ -184,5 +184,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..44d52b38f --- /dev/null +++ b/modules/invoiceOut/back/methods/invoiceOut/createManualInvoice.js @@ -0,0 +1,188 @@ +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) { + // Change state to delivered + 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; + } + + // Set shipped at night + maxShipped.setHours(23, 59, 59, 59); + + // 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/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..9d0cc4337 100644 --- a/modules/invoiceOut/front/index/index.html +++ b/modules/invoiceOut/front/index/index.html @@ -57,6 +57,26 @@ +