feat: refs #7346 manual invoice with address

This commit is contained in:
Javi Gallego 2024-10-02 15:27:09 +02:00
parent 2fbf8478c0
commit 43df98598c
3 changed files with 203 additions and 148 deletions

View File

@ -0,0 +1,56 @@
DELIMITER $$
CREATE OR REPLACE DEFINER=`vn`@`localhost` PROCEDURE `vn`.`invoiceOut_newFromAddress`(
IN vAddressFk 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 consignatario hasta una fecha dada
* @param vAddressFk Id del consignatario a facturar
* @param vSerial Serie de factura
* @param vMaxShipped Fecha hasta la cual cogerá 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 AND vSerial 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;
DROP TEMPORARY TABLE IF EXISTS `tmp`.`ticketToInvoice`;
CREATE TEMPORARY TABLE `tmp`.`ticketToInvoice`
(PRIMARY KEY (`id`))
ENGINE = MEMORY
SELECT id FROM ticket t
WHERE t.addressFk = vAddressFk
AND t.refFk IS NULL
AND t.companyFk = vCompanyFk
AND t.shipped BETWEEN
util.firstDayOfYear(vMaxShipped - INTERVAL 1 YEAR) AND
util.dayend(vMaxShipped);
CALL invoiceOut_new(vSerial, util.VN_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 ;

View File

@ -10,6 +10,11 @@ module.exports = Self => {
type: 'any', type: 'any',
description: 'The invoiceable client id' description: 'The invoiceable client id'
}, },
{
arg: 'addressFk',
type: 'any',
description: 'The consignatary address id'
},
{ {
arg: 'ticketFk', arg: 'ticketFk',
type: 'any', type: 'any',
@ -23,7 +28,8 @@ module.exports = Self => {
{ {
arg: 'serial', arg: 'serial',
type: 'string', type: 'string',
description: 'The invoice serial' description: 'The invoice serial',
required: true
}, },
{ {
arg: 'taxArea', arg: 'taxArea',
@ -46,7 +52,8 @@ module.exports = Self => {
} }
}); });
Self.createManualInvoice = async(ctx, clientFk, ticketFk, maxShipped, serial, taxArea, reference, options) => { Self.createManualInvoice =
async(ctx, clientFk, addressFk, ticketFk, maxShipped, serial, taxArea, reference, options) => {
if (!clientFk && !ticketFk) throw new UserError(`Select ticket or client`); if (!clientFk && !ticketFk) throw new UserError(`Select ticket or client`);
const models = Self.app.models; const models = Self.app.models;
const myOptions = {userId: ctx.req.accessToken.userId}; const myOptions = {userId: ctx.req.accessToken.userId};
@ -60,7 +67,7 @@ module.exports = Self => {
myOptions.transaction = tx; myOptions.transaction = tx;
} }
let companyId; let companyFk;
let newInvoice; let newInvoice;
let query; let query;
try { try {
@ -70,24 +77,27 @@ module.exports = Self => {
clientFk = ticket.clientFk; clientFk = ticket.clientFk;
maxShipped = ticket.shipped; maxShipped = ticket.shipped;
companyId = ticket.companyFk; companyFk = ticket.companyFk;
// Validates invoiced ticket
if (ticket.refFk) if (ticket.refFk)
throw new UserError('This ticket is already invoiced'); throw new UserError('This ticket is already invoiced');
// Validates ticket amount
if (ticket.totalWithVat == 0) if (ticket.totalWithVat == 0)
throw new UserError(`A ticket with an amount of zero can't be invoiced`); throw new UserError(`A ticket with an amount of zero can't be invoiced`);
// Validates ticket nagative base const hasNegativeBase = await getNegativeBase(maxShipped, clientFk, companyFk, myOptions);
const hasNegativeBase = await getNegativeBase(maxShipped, clientFk, companyId, myOptions);
if (hasNegativeBase && company.code == 'VNL') if (hasNegativeBase && company.code == 'VNL')
throw new UserError(`A ticket with a negative base can't be invoiced`); throw new UserError(`A ticket with a negative base can't be invoiced`);
} else { } else {
if (!maxShipped) if (!maxShipped)
throw new UserError(`Max shipped required`); throw new UserError(`Max shipped required`);
if (addressFk) {
const address = await models.Address.findById(addressFk, null, myOptions);
if (clientFk && clientFk !== address.clientFk)
throw new UserError('The provided clientFk does not match');
}
const company = await models.Ticket.findOne({ const company = await models.Ticket.findOne({
fields: ['companyFk'], fields: ['companyFk'],
where: { where: {
@ -95,22 +105,20 @@ module.exports = Self => {
shipped: {lte: maxShipped} shipped: {lte: maxShipped}
} }
}, myOptions); }, myOptions);
companyId = company.companyFk; companyFk = company.companyFk;
} }
// Validate invoiceable client
const isClientInvoiceable = await isInvoiceable(clientFk, myOptions); const isClientInvoiceable = await isInvoiceable(clientFk, myOptions);
if (!isClientInvoiceable) if (!isClientInvoiceable)
throw new UserError(`This client is not invoiceable`); throw new UserError(`This client is not invoiceable`);
// Can't invoice tickets into future
const tomorrow = Date.vnNew(); const tomorrow = Date.vnNew();
tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setDate(tomorrow.getDate() + 1);
if (maxShipped >= tomorrow) if (maxShipped >= tomorrow)
throw new UserError(`Can't invoice to future`); throw new UserError(`Can't invoice to future`);
const maxInvoiceDate = await getMaxIssued(serial, companyId, myOptions); const maxInvoiceDate = await getMaxIssued(serial, companyFk, myOptions);
if (Date.vnNew() < maxInvoiceDate) if (Date.vnNew() < maxInvoiceDate)
throw new UserError(`Can't invoice to past`); throw new UserError(`Can't invoice to past`);
@ -122,13 +130,23 @@ module.exports = Self => {
taxArea, taxArea,
reference reference
], myOptions); ], myOptions);
} else if (addressFk) {
query = `CALL invoiceOut_newFromAddress(?, ?, ?, ?, ?, ?, @newInvoiceId)`;
await Self.rawSql(query, [
addressFk,
serial,
maxShipped,
companyFk,
taxArea,
reference
], myOptions);
} else { } else {
query = `CALL invoiceOut_newFromClient(?, ?, ?, ?, ?, ?, @newInvoiceId)`; query = `CALL invoiceOut_newFromClient(?, ?, ?, ?, ?, ?, @newInvoiceId)`;
await Self.rawSql(query, [ await Self.rawSql(query, [
clientFk, clientFk,
serial, serial,
maxShipped, maxShipped,
companyId, companyFk,
taxArea, taxArea,
reference reference
], myOptions); ], myOptions);
@ -159,10 +177,10 @@ module.exports = Self => {
return result.invoiceable; return result.invoiceable;
} }
async function getNegativeBase(maxShipped, clientFk, companyId, options) { async function getNegativeBase(maxShipped, clientFk, companyFk, options) {
const models = Self.app.models; const models = Self.app.models;
await models.InvoiceOut.rawSql('CALL invoiceOut_exportationFromClient(?,?,?)', await models.InvoiceOut.rawSql('CALL invoiceOut_exportationFromClient(?,?,?)',
[maxShipped, clientFk, companyId], options [maxShipped, clientFk, companyFk], options
); );
const query = 'SELECT vn.hasAnyNegativeBase() AS base'; const query = 'SELECT vn.hasAnyNegativeBase() AS base';
const [result] = await models.InvoiceOut.rawSql(query, [], options); const [result] = await models.InvoiceOut.rawSql(query, [], options);
@ -170,14 +188,14 @@ module.exports = Self => {
return result.base; return result.base;
} }
async function getMaxIssued(serial, companyId, options) { async function getMaxIssued(serial, companyFk, options) {
const models = Self.app.models; const models = Self.app.models;
const query = `SELECT MAX(issued) AS issued const query = `SELECT MAX(issued) AS issued
FROM invoiceOut FROM invoiceOut
WHERE serial = ? AND companyFk = ?`; WHERE serial = ? AND companyFk = ?`;
const [maxIssued] = await models.InvoiceOut.rawSql(query, const [maxIssued] = await models.InvoiceOut.rawSql(query,
[serial, companyId], options); [serial, companyFk], options);
const maxInvoiceDate = maxIssued && maxIssued.issued || Date.vnNew(); const maxInvoiceDate = maxIssued?.issued || Date.vnNew();
return maxInvoiceDate; return maxInvoiceDate;
} }

View File

@ -6,110 +6,90 @@ describe('InvoiceOut createManualInvoice()', () => {
const clientId = 1106; const clientId = 1106;
const activeCtx = {accessToken: {userId: 1}}; const activeCtx = {accessToken: {userId: 1}};
const ctx = {req: activeCtx}; const ctx = {req: activeCtx};
let tx; let options;
beforeEach(async() => {
spyOn(models.InvoiceOut, 'createPdf').and.returnValue(Promise.resolve(true));
spyOn(LoopBackContext, 'getCurrentContext').and.returnValue({
active: activeCtx
});
tx = await models.InvoiceOut.beginTransaction({});
options = {transaction: tx};
});
afterEach(async() => {
await tx.rollback();
});
it('should throw an error trying to invoice again', async() => { it('should throw an error trying to invoice again', async() => {
spyOn(models.InvoiceOut, 'createPdf').and.returnValue(new Promise(resolve => resolve(true)));
const tx = await models.InvoiceOut.beginTransaction({});
const options = {transaction: tx};
let error; let error;
try { try {
await createInvoice(ctx, options, undefined, ticketId); await createInvoice(ctx, options, undefined, undefined, ticketId);
await createInvoice(ctx, options, undefined, ticketId); await createInvoice(ctx, options, undefined, undefined, ticketId);
await tx.rollback();
} catch (e) { } catch (e) {
error = e; error = e;
await tx.rollback();
} }
expect(error.message).toContain('This ticket is already invoiced'); expect(error.message).toContain('This ticket is already invoiced');
}); });
it('should throw an error for a ticket with an amount of zero', async() => { it('should throw an error for a ticket with an amount of zero', async() => {
spyOn(models.InvoiceOut, 'createPdf').and.returnValue(new Promise(resolve => resolve(true)));
spyOn(LoopBackContext, 'getCurrentContext').and.returnValue({
active: activeCtx
});
const tx = await models.InvoiceOut.beginTransaction({});
const options = {transaction: tx};
let error; let error;
try { try {
const ticket = await models.Ticket.findById(ticketId, null, options); const ticket = await models.Ticket.findById(ticketId, null, options);
await ticket.updateAttributes({totalWithVat: 0}, options); await ticket.updateAttributes({totalWithVat: 0}, options);
await createInvoice(ctx, options, undefined, ticketId); await createInvoice(ctx, options, undefined, undefined, ticketId);
await tx.rollback();
} catch (e) { } catch (e) {
error = e; error = e;
await tx.rollback();
} }
expect(error.message).toContain(`A ticket with an amount of zero can't be invoiced`); 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() => { it('should throw an error when the clientFk property is set without the max shipped date', async() => {
spyOn(models.InvoiceOut, 'createPdf').and.returnValue(new Promise(resolve => resolve(true)));
const tx = await models.InvoiceOut.beginTransaction({});
const options = {transaction: tx};
let error; let error;
try { try {
await createInvoice(ctx, options, clientId); await createInvoice(ctx, options, clientId);
await tx.rollback();
} catch (e) { } catch (e) {
error = e; error = e;
await tx.rollback();
} }
expect(error.message).toContain(`Max shipped required`); expect(error.message).toContain(`Max shipped required`);
}); });
it('should throw an error for a non-invoiceable client', async() => { it('should throw an error for a non-invoiceable client', async() => {
spyOn(models.InvoiceOut, 'createPdf').and.returnValue(new Promise(resolve => resolve(true)));
spyOn(LoopBackContext, 'getCurrentContext').and.returnValue({
active: activeCtx
});
const tx = await models.InvoiceOut.beginTransaction({});
const options = {transaction: tx};
let error; let error;
try { try {
const client = await models.Client.findById(clientId, null, options); const client = await models.Client.findById(clientId, null, options);
await client.updateAttributes({isTaxDataChecked: false}, options); await client.updateAttributes({isTaxDataChecked: false}, options);
await createInvoice(ctx, options, undefined, ticketId); await createInvoice(ctx, options, undefined, undefined, ticketId);
await tx.rollback();
} catch (e) { } catch (e) {
error = e; error = e;
await tx.rollback();
} }
expect(error.message).toContain(`This client is not invoiceable`); expect(error.message).toContain(`This client is not invoiceable`);
}); });
it('should create a manual invoice', async() => { it('should create a manual invoice with ticket', async() => {
spyOn(models.InvoiceOut, 'createPdf').and.returnValue(new Promise(resolve => resolve(true))); const result = await createInvoice(ctx, options, undefined, undefined, ticketId);
const tx = await models.InvoiceOut.beginTransaction({});
const options = {transaction: tx};
try {
const result = await createInvoice(ctx, options, undefined, ticketId);
expect(result.id).toEqual(jasmine.any(Number)); expect(result.id).toEqual(jasmine.any(Number));
});
await tx.rollback(); it('should create a manual invoice with client', async() => {
} catch (e) { const result = await createInvoice(ctx, options, clientId, undefined, undefined, Date.vnNew());
await tx.rollback();
throw e; expect(result.id).toEqual(jasmine.any(Number));
} });
it('should create a manual invoice with address', async() => {
const addressFk = 126;
const result = await createInvoice(ctx, options, clientId, addressFk, undefined, Date.vnNew());
expect(result.id).toEqual(jasmine.any(Number));
}); });
}); });
@ -117,6 +97,7 @@ function createInvoice(
ctx, ctx,
options, options,
clientFk = undefined, clientFk = undefined,
addressFk = undefined,
ticketFk = undefined, ticketFk = undefined,
maxShipped = undefined, maxShipped = undefined,
serial = 'T', serial = 'T',
@ -124,6 +105,6 @@ function createInvoice(
reference = undefined reference = undefined
) { ) {
return models.InvoiceOut.createManualInvoice( return models.InvoiceOut.createManualInvoice(
ctx, clientFk, ticketFk, maxShipped, serial, taxArea, reference, options ctx, clientFk, addressFk, ticketFk, maxShipped, serial, taxArea, reference, options
); );
} }