Merge pull request '5914-transferInvoiceOut' (!1761) from 5914-transferInvoiceOut into dev
gitea/salix/pipeline/head This commit looks good Details

Reviewed-on: #1761
Reviewed-by: Javi Gallego <jgallego@verdnatura.es>
Reviewed-by: Alex Moreno <alexm@verdnatura.es>
This commit is contained in:
Jorge Penadés 2023-10-26 12:39:07 +00:00
commit 7fe0641817
30 changed files with 589 additions and 145 deletions

View File

@ -0,0 +1,6 @@
INSERT INTO `salix`.`ACL` (model, property, accessType, permission, principalType, principalId)
VALUES
('CplusRectificationType', '*', 'READ', 'ALLOW', 'ROLE', 'administrative'),
('CplusInvoiceType477', '*', 'READ', 'ALLOW', 'ROLE', 'administrative'),
('InvoiceCorrectionType', '*', 'READ', 'ALLOW', 'ROLE', 'administrative'),
('InvoiceOut', 'transferInvoice', 'WRITE', 'ALLOW', 'ROLE', 'administrative');

View File

@ -604,7 +604,7 @@ INSERT INTO `vn`.`invoiceOutSerial` (`code`, `description`, `isTaxed`, `taxAreaF
INSERT INTO `vn`.`invoiceOut`(`id`, `serial`, `amount`, `issued`,`clientFk`, `created`, `companyFk`, `dued`, `booked`, `bankFk`, `hasPdf`) INSERT INTO `vn`.`invoiceOut`(`id`, `serial`, `amount`, `issued`,`clientFk`, `created`, `companyFk`, `dued`, `booked`, `bankFk`, `hasPdf`)
VALUES VALUES
(1, 'T', 1014.24, util.VN_CURDATE(), 1101, util.VN_CURDATE(), 442, util.VN_CURDATE(), util.VN_CURDATE(), 1, 0), (1, 'T', 1026.24, util.VN_CURDATE(), 1101, util.VN_CURDATE(), 442, util.VN_CURDATE(), util.VN_CURDATE(), 1, 0),
(2, 'T', 121.36, util.VN_CURDATE(), 1102, util.VN_CURDATE(), 442, util.VN_CURDATE(), util.VN_CURDATE(), 1, 0), (2, 'T', 121.36, util.VN_CURDATE(), 1102, util.VN_CURDATE(), 442, util.VN_CURDATE(), util.VN_CURDATE(), 1, 0),
(3, 'T', 8.88, util.VN_CURDATE(), 1103, util.VN_CURDATE(), 442, util.VN_CURDATE(), util.VN_CURDATE(), 1, 0), (3, 'T', 8.88, util.VN_CURDATE(), 1103, util.VN_CURDATE(), 442, util.VN_CURDATE(), util.VN_CURDATE(), 1, 0),
(4, 'T', 8.88, util.VN_CURDATE(), 1103, util.VN_CURDATE(), 442, util.VN_CURDATE(), util.VN_CURDATE(), 1, 0), (4, 'T', 8.88, util.VN_CURDATE(), 1103, util.VN_CURDATE(), 442, util.VN_CURDATE(), util.VN_CURDATE(), 1, 0),
@ -2977,3 +2977,9 @@ INSERT INTO vn.XDiario (id, ASIEN, FECHA, SUBCTA, CONTRA, CONCEPTO, EURODEBE, EU
INSERT INTO `vn`.`mistakeType` (`id`, `description`) INSERT INTO `vn`.`mistakeType` (`id`, `description`)
VALUES VALUES
(1, 'Incorrect quantity'); (1, 'Incorrect quantity');
INSERT INTO `vn`.`invoiceCorrectionType` (`id`, `description`)
VALUES
(1, 'Error in VAT calculation'),
(2, 'Error in sales details'),
(3, 'Error in customer data');

View File

@ -212,7 +212,7 @@ describe('Ticket Edit sale path', () => {
it('should log in as salesAssistant and navigate to ticket sales', async() => { it('should log in as salesAssistant and navigate to ticket sales', async() => {
await page.loginAndModule('salesAssistant', 'ticket'); await page.loginAndModule('salesAssistant', 'ticket');
await page.accessToSearchResult('17'); await page.accessToSearchResult('15');
await page.accessToSection('ticket.card.sale'); await page.accessToSection('ticket.card.sale');
}); });
@ -316,7 +316,7 @@ describe('Ticket Edit sale path', () => {
}); });
it('should confirm the transfered quantity is the correct one', async() => { it('should confirm the transfered quantity is the correct one', async() => {
const result = await page.waitToGetProperty(selectors.ticketSales.secondSaleQuantityCell, 'innerText'); const result = await page.waitToGetProperty(selectors.ticketSales.firstSaleQuantityCell, 'innerText');
expect(result).toContain('20'); expect(result).toContain('20');
}); });
@ -370,7 +370,7 @@ describe('Ticket Edit sale path', () => {
await page.waitToClick(selectors.ticketSales.moveToNewTicketButton); await page.waitToClick(selectors.ticketSales.moveToNewTicketButton);
const message = await page.waitForSnackbar(); const message = await page.waitForSnackbar();
expect(message.text).toContain(`You can't create a ticket for a inactive client`); expect(message.text).toContain(`You can't create a ticket for an inactive client`);
await page.closePopup(); await page.closePopup();
}); });

View File

@ -23,7 +23,7 @@
"Agency cannot be blank": "Agency cannot be blank", "Agency cannot be blank": "Agency cannot be blank",
"The IBAN does not have the correct format": "The IBAN does not have the correct format", "The IBAN does not have the correct format": "The IBAN does not have the correct format",
"You can't make changes on the basic data of an confirmed order or with rows": "You can't make changes on the basic data of an confirmed order or with rows", "You can't make changes on the basic data of an confirmed order or with rows": "You can't make changes on the basic data of an confirmed order or with rows",
"You can't create a ticket for a inactive client": "You can't create a ticket for a inactive client", "You can't create a ticket for an inactive client": "You can't create a ticket for an inactive client",
"Worker cannot be blank": "Worker cannot be blank", "Worker cannot be blank": "Worker cannot be blank",
"You must delete the claim id %d first": "You must delete the claim id %d first", "You must delete the claim id %d first": "You must delete the claim id %d first",
"You don't have enough privileges": "You don't have enough privileges", "You don't have enough privileges": "You don't have enough privileges",
@ -188,7 +188,14 @@
"The ticket doesn't exist.": "The ticket doesn't exist.", "The ticket doesn't exist.": "The ticket doesn't exist.",
"The sales do not exists": "The sales do not exists", "The sales do not exists": "The sales do not exists",
"Ticket without Route": "Ticket without route", "Ticket without Route": "Ticket without route",
"Select a different client": "Select a different client",
"Fill all the fields": "Fill all the fields",
"Error while generating PDF": "Error while generating PDF",
"Can't invoice to future": "Can't invoice to future",
"This ticket is already invoiced": "This ticket is already invoiced",
"Negative basis of tickets: 23": "Negative basis of tickets: 23",
"Booking completed": "Booking complete", "Booking completed": "Booking complete",
"The ticket is in preparation": "The ticket [{{ticketId}}]({{{ticketUrl}}}) of the sales person {{salesPersonId}} is in preparation", "The ticket is in preparation": "The ticket [{{ticketId}}]({{{ticketUrl}}}) of the sales person {{salesPersonId}} is in preparation",
"You can only add negative amounts in refund tickets": "You can only add negative amounts in refund tickets" "You can only add negative amounts in refund tickets": "You can only add negative amounts in refund tickets"
} }

View File

@ -5,10 +5,10 @@
"The default consignee can not be unchecked": "No se puede desmarcar el consignatario predeterminado", "The default consignee can not be unchecked": "No se puede desmarcar el consignatario predeterminado",
"Unable to default a disabled consignee": "No se puede poner predeterminado un consignatario desactivado", "Unable to default a disabled consignee": "No se puede poner predeterminado un consignatario desactivado",
"Can't be blank": "No puede estar en blanco", "Can't be blank": "No puede estar en blanco",
"Invalid TIN": "NIF/CIF invalido", "Invalid TIN": "NIF/CIF inválido",
"TIN must be unique": "El NIF/CIF debe ser único", "TIN must be unique": "El NIF/CIF debe ser único",
"A client with that Web User name already exists": "Ya existe un cliente con ese Usuario Web", "A client with that Web User name already exists": "Ya existe un cliente con ese Usuario Web",
"Is invalid": "Is invalid", "Is invalid": "Es inválido",
"Quantity cannot be zero": "La cantidad no puede ser cero", "Quantity cannot be zero": "La cantidad no puede ser cero",
"Enter an integer different to zero": "Introduce un entero distinto de cero", "Enter an integer different to zero": "Introduce un entero distinto de cero",
"Package cannot be blank": "El embalaje no puede estar en blanco", "Package cannot be blank": "El embalaje no puede estar en blanco",
@ -55,17 +55,17 @@
"You must delete the claim id %d first": "Antes debes borrar la reclamación %d", "You must delete the claim id %d first": "Antes debes borrar la reclamación %d",
"You don't have enough privileges": "No tienes suficientes permisos", "You don't have enough privileges": "No tienes suficientes permisos",
"Cannot check Equalization Tax in this NIF/CIF": "No se puede marcar RE en este NIF/CIF", "Cannot check Equalization Tax in this NIF/CIF": "No se puede marcar RE en este NIF/CIF",
"You can't make changes on the basic data of an confirmed order or with rows": "No puedes cambiar los datos basicos de una orden con artículos", "You can't make changes on the basic data of an confirmed order or with rows": "No puedes cambiar los datos básicos de una orden con artículos",
"INVALID_USER_NAME": "El nombre de usuario solo debe contener letras minúsculas o, a partir del segundo carácter, números o subguiones, no esta permitido el uso de la letra ñ", "INVALID_USER_NAME": "El nombre de usuario solo debe contener letras minúsculas o, a partir del segundo carácter, números o subguiones, no está permitido el uso de la letra ñ",
"You can't create a ticket for a frozen client": "No puedes crear un ticket para un cliente congelado", "You can't create a ticket for a frozen client": "No puedes crear un ticket para un cliente congelado",
"You can't create a ticket for a inactive client": "No puedes crear un ticket para un cliente inactivo", "You can't create a ticket for an inactive client": "No puedes crear un ticket para un cliente inactivo",
"Tag value cannot be blank": "El valor del tag no puede quedar en blanco", "Tag value cannot be blank": "El valor del tag no puede quedar en blanco",
"ORDER_EMPTY": "Cesta vacía", "ORDER_EMPTY": "Cesta vacía",
"You don't have enough privileges to do that": "No tienes permisos para cambiar esto", "You don't have enough privileges to do that": "No tienes permisos para cambiar esto",
"NO SE PUEDE DESACTIVAR EL CONSIGNAT": "NO SE PUEDE DESACTIVAR EL CONSIGNAT", "NO SE PUEDE DESACTIVAR EL CONSIGNAT": "NO SE PUEDE DESACTIVAR EL CONSIGNAT",
"Error. El NIF/CIF está repetido": "Error. El NIF/CIF está repetido", "Error. El NIF/CIF está repetido": "Error. El NIF/CIF está repetido",
"Street cannot be empty": "Dirección no puede estar en blanco", "Street cannot be empty": "Dirección no puede estar en blanco",
"City cannot be empty": "Cuidad no puede estar en blanco", "City cannot be empty": "Ciudad no puede estar en blanco",
"Code cannot be blank": "Código no puede estar en blanco", "Code cannot be blank": "Código no puede estar en blanco",
"You cannot remove this department": "No puedes eliminar este departamento", "You cannot remove this department": "No puedes eliminar este departamento",
"The extension must be unique": "La extensión debe ser unica", "The extension must be unique": "La extensión debe ser unica",
@ -102,8 +102,8 @@
"You can't delete a confirmed order": "No puedes borrar un pedido confirmado", "You can't delete a confirmed order": "No puedes borrar un pedido confirmado",
"The social name has an invalid format": "El nombre fiscal tiene un formato incorrecto", "The social name has an invalid format": "El nombre fiscal tiene un formato incorrecto",
"Invalid quantity": "Cantidad invalida", "Invalid quantity": "Cantidad invalida",
"This postal code is not valid": "This postal code is not valid", "This postal code is not valid": "Este código postal no es válido",
"is invalid": "is invalid", "is invalid": "es inválido",
"The postcode doesn't exist. Please enter a correct one": "El código postal no existe. Por favor, introduce uno correcto", "The postcode doesn't exist. Please enter a correct one": "El código postal no existe. Por favor, introduce uno correcto",
"The department name can't be repeated": "El nombre del departamento no puede repetirse", "The department name can't be repeated": "El nombre del departamento no puede repetirse",
"This phone already exists": "Este teléfono ya existe", "This phone already exists": "Este teléfono ya existe",
@ -112,8 +112,8 @@
"You cannot delete a ticket that part of it is being prepared": "No puedes eliminar un ticket en el que una parte que está siendo preparada", "You cannot delete a ticket that part of it is being prepared": "No puedes eliminar un ticket en el que una parte que está siendo preparada",
"You must delete all the buy requests first": "Debes eliminar todas las peticiones de compra primero", "You must delete all the buy requests first": "Debes eliminar todas las peticiones de compra primero",
"You should specify a date": "Debes especificar una fecha", "You should specify a date": "Debes especificar una fecha",
"You should specify at least a start or end date": "Debes especificar al menos una fecha de inicio o de fín", "You should specify at least a start or end date": "Debes especificar al menos una fecha de inicio o de fin",
"Start date should be lower than end date": "La fecha de inicio debe ser menor que la fecha de fín", "Start date should be lower than end date": "La fecha de inicio debe ser menor que la fecha de fin",
"You should mark at least one week day": "Debes marcar al menos un día de la semana", "You should mark at least one week day": "Debes marcar al menos un día de la semana",
"Swift / BIC can't be empty": "Swift / BIC no puede estar vacío", "Swift / BIC can't be empty": "Swift / BIC no puede estar vacío",
"Customs agent is required for a non UEE member": "El agente de aduanas es requerido para los clientes extracomunitarios", "Customs agent is required for a non UEE member": "El agente de aduanas es requerido para los clientes extracomunitarios",
@ -144,15 +144,15 @@
"Unable to clone this travel": "No ha sido posible clonar este travel", "Unable to clone this travel": "No ha sido posible clonar este travel",
"This thermograph id already exists": "La id del termógrafo ya existe", "This thermograph id already exists": "La id del termógrafo ya existe",
"Choose a date range or days forward": "Selecciona un rango de fechas o días en adelante", "Choose a date range or days forward": "Selecciona un rango de fechas o días en adelante",
"ORDER_ALREADY_CONFIRMED": "ORDER_ALREADY_CONFIRMED", "ORDER_ALREADY_CONFIRMED": "ORDEN YA CONFIRMADA",
"Invalid password": "Invalid password", "Invalid password": "Invalid password",
"Password does not meet requirements": "La contraseña no cumple los requisitos", "Password does not meet requirements": "La contraseña no cumple los requisitos",
"Role already assigned": "Role already assigned", "Role already assigned": "Rol ya asignado",
"Invalid role name": "Invalid role name", "Invalid role name": "Nombre de rol no válido",
"Role name must be written in camelCase": "Role name must be written in camelCase", "Role name must be written in camelCase": "El nombre del rol debe escribirse en camelCase",
"Email already exists": "Email already exists", "Email already exists": "El correo ya existe",
"User already exists": "User already exists", "User already exists": "El/La usuario/a ya existe",
"Absence change notification on the labour calendar": "Notificacion de cambio de ausencia en el calendario laboral", "Absence change notification on the labour calendar": "Notificación de cambio de ausencia en el calendario laboral",
"Record of hours week": "Registro de horas semana {{week}} año {{year}} ", "Record of hours week": "Registro de horas semana {{week}} año {{year}} ",
"Created absence": "El empleado <strong>{{author}}</strong> ha añadido una ausencia de tipo '{{absenceType}}' a <a href='{{{workerUrl}}}'><strong>{{employee}}</strong></a> para el día {{dated}}.", "Created absence": "El empleado <strong>{{author}}</strong> ha añadido una ausencia de tipo '{{absenceType}}' a <a href='{{{workerUrl}}}'><strong>{{employee}}</strong></a> para el día {{dated}}.",
"Deleted absence": "El empleado <strong>{{author}}</strong> ha eliminado una ausencia de tipo '{{absenceType}}' a <a href='{{{workerUrl}}}'><strong>{{employee}}</strong></a> del día {{dated}}.", "Deleted absence": "El empleado <strong>{{author}}</strong> ha eliminado una ausencia de tipo '{{absenceType}}' a <a href='{{{workerUrl}}}'><strong>{{employee}}</strong></a> del día {{dated}}.",
@ -317,6 +317,9 @@
"The ticket doesn't exist.": "No existe el ticket.", "The ticket doesn't exist.": "No existe el ticket.",
"Social name should be uppercase": "La razón social debe ir en mayúscula", "Social name should be uppercase": "La razón social debe ir en mayúscula",
"Street should be uppercase": "La dirección fiscal debe ir en mayúscula", "Street should be uppercase": "La dirección fiscal debe ir en mayúscula",
"Ticket without Route": "Ticket sin ruta",
"Select a different client": "Seleccione un cliente distinto",
"Fill all the fields": "Rellene todos los campos",
"The response is not a PDF": "La respuesta no es un PDF", "The response is not a PDF": "La respuesta no es un PDF",
"Ticket without Route": "Ticket sin ruta", "Ticket without Route": "Ticket sin ruta",
"Booking completed": "Reserva completada", "Booking completed": "Reserva completada",

View File

@ -23,7 +23,7 @@ module.exports = Self => {
} }
}); });
Self.makePdfAndNotify = async function(ctx, id, printerFk) { Self.makePdfAndNotify = async function(ctx, id, printerFk, options) {
const models = Self.app.models; const models = Self.app.models;
options = typeof options == 'object' options = typeof options == 'object'

View File

@ -3,7 +3,7 @@ const LoopBackContext = require('loopback-context');
describe('InvoiceOut refund()', () => { describe('InvoiceOut refund()', () => {
const userId = 5; const userId = 5;
const ctx = {req: {accessToken: userId}}; const ctx = {req: {accessToken: userId}, args: {}};
const withWarehouse = true; const withWarehouse = true;
const activeCtx = { const activeCtx = {
accessToken: {userId: userId}, accessToken: {userId: userId},

View File

@ -0,0 +1,68 @@
const models = require('vn-loopback/server/server').models;
const LoopBackContext = require('loopback-context');
describe('InvoiceOut tranferInvoice()', () => {
const activeCtx = {
accessToken: {userId: 5},
http: {
req: {
headers: {origin: 'http://localhost'}
}
}
};
const ctx = {req: activeCtx};
beforeEach(() => {
spyOn(LoopBackContext, 'getCurrentContext').and.returnValue({
active: activeCtx
});
});
it('should return the id of the created issued invoice', async() => {
const tx = await models.InvoiceOut.beginTransaction({});
const options = {transaction: tx};
const args = {
id: '1',
ref: 'T4444444',
newClientFk: 1,
cplusRectificationId: 1,
cplusInvoiceType477Id: 1,
invoiceCorrectionTypeId: 1
};
ctx.args = args;
try {
const result = await models.InvoiceOut.transferInvoice(
ctx,
options);
expect(result).toBeDefined();
await tx.rollback();
} catch (e) {
await tx.rollback();
throw e;
}
});
it('should throw an UserError when it is the same client', async() => {
const tx = await models.InvoiceOut.beginTransaction({});
const options = {transaction: tx};
const args = {
id: '1',
ref: 'T1111111',
newClientFk: 1101,
cplusRectificationId: 1,
cplusInvoiceType477Id: 1,
invoiceCorrectionTypeId: 1
};
ctx.args = args;
try {
await models.InvoiceOut.transferInvoice(
ctx,
options);
} catch (e) {
expect(e.message).toBe(`Select a different client`);
await tx.rollback();
}
});
});

View File

@ -0,0 +1,111 @@
const UserError = require('vn-loopback/util/user-error');
module.exports = Self => {
Self.remoteMethodCtx('transferInvoice', {
description: 'Transfer an issued invoice to another client',
accessType: 'WRITE',
accepts: [
{
arg: 'id',
type: 'number',
required: true,
description: 'Issued invoice id'
},
{
arg: 'ref',
type: 'string',
required: true
},
{
arg: 'newClientFk',
type: 'number',
required: true
},
{
arg: 'cplusRectificationId',
type: 'number',
required: true
},
{
arg: 'cplusInvoiceType477Id',
type: 'number',
required: true
},
{
arg: 'invoiceCorrectionTypeId',
type: 'number',
required: true
},
],
returns: {
type: 'boolean',
root: true
},
http: {
path: '/transferInvoice',
verb: 'post'
}
});
Self.transferInvoice = async(ctx, options) => {
const models = Self.app.models;
const myOptions = {userId: ctx.req.accessToken.userId};
const args = ctx.args;
let tx;
if (typeof options == 'object')
Object.assign(myOptions, options);
const {clientFk} = await models.InvoiceOut.findById(args.id);
if (clientFk == args.newClientFk)
throw new UserError(`Select a different client`);
if (!myOptions.transaction) {
tx = await Self.beginTransaction({});
myOptions.transaction = tx;
}
try {
const filterRef = {where: {refFk: args.ref}};
const tickets = await models.Ticket.find(filterRef, myOptions);
const ticketsIds = tickets.map(ticket => ticket.id);
await models.Ticket.refund(ctx, ticketsIds, null, myOptions);
const filterTicket = {where: {ticketFk: {inq: ticketsIds}}};
const services = await models.TicketService.find(filterTicket, myOptions);
const servicesIds = services.map(service => service.id);
const sales = await models.Sale.find(filterTicket, myOptions);
const salesIds = sales.map(sale => sale.id);
const clonedTickets = await models.Sale.clone(ctx, salesIds, servicesIds, null, false, false, myOptions);
const clonedTicketIds = [];
for (const clonedTicket of clonedTickets) {
await clonedTicket.updateAttribute('clientFk', args.newClientFk, myOptions);
clonedTicketIds.push(clonedTicket.id);
}
const invoiceIds = await models.Ticket.invoiceTickets(ctx, clonedTicketIds, myOptions);
const [invoiceId] = invoiceIds;
await models.InvoiceCorrection.create({
correctingFk: invoiceId,
correctedFk: args.id,
cplusRectificationTypeFk: args.cplusRectificationId,
cplusInvoiceType477Fk: args.cplusInvoiceType477Id,
invoiceCorrectionTypeFk: args.invoiceCorrectionTypeId
}, myOptions);
if (tx) {
await tx.commit();
await models.InvoiceOut.makePdfAndNotify(ctx, invoiceId, null);
}
return invoiceId;
} catch (e) {
if (tx) await tx.rollback();
throw e;
}
};
};

View File

@ -31,5 +31,17 @@
}, },
"ZipConfig": { "ZipConfig": {
"dataSource": "vn" "dataSource": "vn"
},
"CplusRectificationType": {
"dataSource": "vn"
},
"InvoiceCorrectionType": {
"dataSource": "vn"
},
"InvoiceCorrection": {
"dataSource": "vn"
},
"CplusInvoiceType477": {
"dataSource": "vn"
} }
} }

View File

@ -0,0 +1,19 @@
{
"name": "CplusInvoiceType477",
"base": "VnModel",
"options": {
"mysql": {
"table": "cplusInvoiceType477"
}
},
"properties": {
"id": {
"id": true,
"type": "number",
"description": "Identifier"
},
"description": {
"type": "string"
}
}
}

View File

@ -0,0 +1,19 @@
{
"name": "CplusRectificationType",
"base": "VnModel",
"options": {
"mysql": {
"table": "cplusRectificationType"
}
},
"properties": {
"id": {
"id": true,
"type": "number",
"description": "Identifier"
},
"description": {
"type": "string"
}
}
}

View File

@ -0,0 +1,19 @@
{
"name": "InvoiceCorrectionType",
"base": "VnModel",
"options": {
"mysql": {
"table": "invoiceCorrectionType"
}
},
"properties": {
"id": {
"id": true,
"type": "number",
"description": "Identifier"
},
"description": {
"type": "string"
}
}
}

View File

@ -0,0 +1,28 @@
{
"name": "InvoiceCorrection",
"base": "VnModel",
"options": {
"mysql": {
"table": "invoiceCorrection"
}
},
"properties": {
"correctingFk": {
"id": true,
"type": "number",
"description": "Identifier"
},
"correctedFk": {
"type": "number"
},
"cplusRectificationTypeFk": {
"type": "number"
},
"cplusInvoiceType477Fk": {
"type": "number"
},
"invoiceCorrectionTypeFk": {
"type": "number"
}
}
}

View File

@ -23,6 +23,7 @@ module.exports = Self => {
require('../methods/invoiceOut/getInvoiceDate')(Self); require('../methods/invoiceOut/getInvoiceDate')(Self);
require('../methods/invoiceOut/negativeBases')(Self); require('../methods/invoiceOut/negativeBases')(Self);
require('../methods/invoiceOut/negativeBasesCsv')(Self); require('../methods/invoiceOut/negativeBasesCsv')(Self);
require('../methods/invoiceOut/transferInvoice')(Self);
Self.filePath = async function(id, options) { Self.filePath = async function(id, options) {
const fields = ['ref', 'issued']; const fields = ['ref', 'issued'];

View File

@ -1,3 +1,19 @@
<vn-crud-model
auto-load="true"
url="CplusRectificationTypes"
data="cplusRectificationTypes"
order="description">
</vn-crud-model>
<vn-crud-model
auto-load="true"
url="CplusInvoiceType477s"
data="cplusInvoiceType477">
</vn-crud-model>
<vn-crud-model
auto-load="true"
url="InvoiceCorrectionTypes"
data="invoiceCorrectionTypes">
</vn-crud-model>
<vn-icon-button <vn-icon-button
icon="more_vert" icon="more_vert"
@ -5,6 +21,13 @@
</vn-icon-button> </vn-icon-button>
<vn-menu vn-id="menu"> <vn-menu vn-id="menu">
<vn-list> <vn-list>
<vn-item
vn-acl="administrative"
vn-acl-action="remove"
ng-click="transferInvoice.show()"
translate>
Transfer invoice to...
</vn-item>
<vn-item class="dropdown" <vn-item class="dropdown"
vn-click-stop="showInvoiceMenu.show($event, 'left')" vn-click-stop="showInvoiceMenu.show($event, 'left')"
name="showInvoicePdf" name="showInvoicePdf"
@ -157,3 +180,71 @@
<button response="accept" translate>Confirm</button> <button response="accept" translate>Confirm</button>
</tpl-buttons> </tpl-buttons>
</vn-dialog> </vn-dialog>
<vn-dialog
vn-id="transferInvoice"
title="transferInvoice"
on-accept="$ctrl.transferInvoice()">
<tpl-body>
<section class="transferInvoice">
<vn-horizontal>
<vn-autocomplete
vn-one
vn-id="client"
required="true"
url="Clients"
label="Client"
show-field="name"
value-field="id"
search-function="{or: [{id: $search}, {name: {like: '%'+ $search +'%'}}]}"
ng-model="$ctrl.invoiceOut.client.id"
initial-data="$ctrl.invoiceOut.client.id"
order="id">
<tpl-item>
#{{id}} - {{::name}}
</tpl-item>
</vn-autocomplete>
<vn-autocomplete
vn-one
vn-id="cplusRectificationType"
required="true"
data="cplusRectificationTypes"
show-field="description"
value-field="id"
ng-model="$ctrl.cplusRectificationType"
search-function="{or: [{id: $search}, {description: {like: '%'+ $search +'%'}}]}"
label="Cplus Type">
<tpl-item>
{{::description}}
</tpl-item>
</vn-autocomplete>
</vn-horizontal>
<vn-horizontal>
<vn-autocomplete
vn-one
vn-id="cplusInvoiceType"
data="cplusInvoiceType477"
show-field="description"
value-field="id"
required="true"
ng-model="$ctrl.cplusInvoiceType477"
search-function="{or: [{id: $search}, {description: {like: '%'+ $search +'%'}}]}"
label="Class">
</vn-autocomplete>
<vn-autocomplete
vn-one
vn-id="invoiceCorrectionType"
data="invoiceCorrectionTypes"
ng-model="$ctrl.invoiceCorrectionType"
show-field="description"
value-field="id"
required="true"
label="Type">
</vn-autocomplete>
</vn-horizontal>
</section>
</tpl-body>
<tpl-buttons>
<button response="accept" translate>Transfer client</button>
</tpl-buttons>
</vn-dialog>

View File

@ -125,6 +125,22 @@ class Controller extends Section {
this.$state.go('ticket.card.sale', {id: refundTicket.id}); this.$state.go('ticket.card.sale', {id: refundTicket.id});
}); });
} }
transferInvoice() {
const params = {
id: this.invoiceOut.id,
ref: this.invoiceOut.ref,
newClientFk: this.invoiceOut.client.id,
cplusRectificationId: this.cplusRectificationType,
cplusInvoiceType477Id: this.cplusInvoiceType477,
invoiceCorrectionTypeId: this.invoiceCorrectionType
};
this.$http.post(`InvoiceOuts/transferInvoice`, params).then(res => {
const invoiceId = res.data;
this.vnApp.showSuccess(this.$t('Invoice trasfered!'));
this.$state.go('invoiceOut.card.summary', {id: invoiceId});
});
}
} }
Controller.$inject = ['$element', '$scope', 'vnReport', 'vnEmail']; Controller.$inject = ['$element', '$scope', 'vnReport', 'vnEmail'];

View File

@ -1 +1,3 @@
The following refund tickets have been created: "The following refund tickets have been created: {{ticketIds}}" The following refund tickets have been created: "The following refund tickets have been created: {{ticketIds}}"
Transfer invoice to...: Transfer invoice to...
Cplus Type: Cplus Type

View File

@ -21,3 +21,5 @@ The invoice PDF document has been regenerated: El documento PDF de la factura ha
The email can't be empty: El correo no puede estar vacío The email can't be empty: El correo no puede estar vacío
The following refund tickets have been created: "Se han creado los siguientes tickets de abono: {{ticketIds}}" The following refund tickets have been created: "Se han creado los siguientes tickets de abono: {{ticketIds}}"
Refund...: Abono... Refund...: Abono...
Transfer invoice to...: Transferir factura a...
Cplus Type: Cplus Tipo

View File

@ -21,4 +21,10 @@ vn-invoice-out-descriptor-menu {
font-size: 1.75rem; font-size: 1.75rem;
} }
} }
}
}
@media screen and (min-width: 1000px) {
.transferInvoice {
min-width: $width-md;
}
}

View File

@ -0,0 +1,122 @@
module.exports = Self => {
Self.clone = async(ctx, salesIds, servicesIds, withWarehouse, group, negative, options) => {
const models = Self.app.models;
const myOptions = {};
let tx;
const newTickets = [];
if (typeof options == 'object')
Object.assign(myOptions, options);
if (!myOptions.transaction) {
tx = await Self.beginTransaction({});
myOptions.transaction = tx;
}
try {
const salesFilter = {
where: {id: {inq: salesIds}},
include: {
relation: 'components',
scope: {
fields: ['saleFk', 'componentFk', 'value']
}
}
};
const sales = await models.Sale.find(salesFilter, myOptions);
let ticketsIds = [...new Set(sales.map(sale => sale.ticketFk))];
const mappedTickets = new Map();
if (group) ticketsIds = [ticketsIds[0]];
for (let ticketId of ticketsIds) {
const newTicket = await createTicket(
ctx,
ticketId,
withWarehouse,
negative,
myOptions
);
newTickets.push(newTicket);
mappedTickets.set(ticketId, newTicket.id);
}
for (const sale of sales) {
const newTicketId = mappedTickets.get(sale.ticketFk);
const createdSale = await models.Sale.create({
ticketFk: newTicketId,
itemFk: sale.itemFk,
quantity: negative ? - sale.quantity : sale.quantity,
concept: sale.concept,
price: sale.price,
discount: sale.discount,
}, myOptions);
const components = sale.components();
for (const component of components)
component.saleFk = createdSale.id;
await models.SaleComponent.create(components, myOptions);
}
if (servicesIds && servicesIds.length) {
const servicesFilter = {
where: {id: {inq: servicesIds}}
};
const services = await models.TicketService.find(servicesFilter, myOptions);
for (const service of services) {
const newTicketId = mappedTickets.get(service.ticketFk);
await models.TicketService.create({
description: service.description,
quantity: negative ? - service.quantity : service.quantity,
price: service.price,
taxClassFk: service.taxClassFk,
ticketFk: newTicketId,
ticketServiceTypeFk: service.ticketServiceTypeFk,
}, myOptions);
}
}
if (tx) await tx.commit();
return newTickets;
} catch (e) {
if (tx) await tx.rollback();
throw e;
}
async function createTicket(
ctx,
ticketId,
withWarehouse,
negative,
myOptions
) {
const models = Self.app.models;
const now = Date.vnNew();
const ticket = await models.Ticket.findById(ticketId, null, myOptions);
ctx.args.clientId = ticket.clientFk;
ctx.args.shipped = now;
ctx.args.landed = now;
ctx.args.warehouseId = withWarehouse ? ticket.warehouseFk : null;
ctx.args.companyId = ticket.companyFk;
ctx.args.addressId = ticket.addressFk;
const newTicket = await models.Ticket.new(ctx, myOptions);
if (negative) {
await models.TicketRefund.create({
originalTicketFk: ticketId,
refundTicketFk: newTicket.id
}, myOptions);
}
return newTicket;
}
};
};

View File

@ -5,7 +5,8 @@ module.exports = Self => {
accepts: [ accepts: [
{ {
arg: 'salesIds', arg: 'salesIds',
type: ['number'] type: ['number'],
required: true
}, },
{ {
arg: 'servicesIds', arg: 'servicesIds',
@ -40,122 +41,23 @@ module.exports = Self => {
myOptions.transaction = tx; myOptions.transaction = tx;
} }
let refundTicket = null;
try { try {
const refundAgencyMode = await models.AgencyMode.findOne({ const refundsTicket = await models.Sale.clone(
include: { ctx,
relation: 'zones', salesIds,
scope: { servicesIds,
limit: 1, withWarehouse,
field: ['id', 'name'] false,
} true,
}, myOptions
where: {code: 'refund'} );
}, myOptions);
const refoundZoneId = refundAgencyMode.zones()[0].id;
if (salesIds.length) {
const salesFilter = {
where: {id: {inq: salesIds}},
include: {
relation: 'components',
scope: {
fields: ['saleFk', 'componentFk', 'value']
}
}
};
const sales = await models.Sale.find(salesFilter, myOptions);
const ticketsIds = [...new Set(sales.map(sale => sale.ticketFk))];
const now = Date.vnNew();
const [firstTicketId] = ticketsIds;
// eslint-disable-next-line max-len
refundTicket = await createTicketRefund(firstTicketId, now, refundAgencyMode, refoundZoneId, withWarehouse, myOptions);
for (const sale of sales) {
const createdSale = await models.Sale.create({
ticketFk: refundTicket.id,
itemFk: sale.itemFk,
quantity: - sale.quantity,
concept: sale.concept,
price: sale.price,
discount: sale.discount,
}, myOptions);
const components = sale.components();
for (const component of components)
component.saleFk = createdSale.id;
await models.SaleComponent.create(components, myOptions);
}
}
if (!refundTicket) {
const servicesFilter = {
where: {id: {inq: servicesIds}}
};
const services = await models.TicketService.find(servicesFilter, myOptions);
const firstTicketId = services[0].ticketFk;
const now = Date.vnNew();
// eslint-disable-next-line max-len
refundTicket = await createTicketRefund(firstTicketId, now, refundAgencyMode, refoundZoneId, withWarehouse, myOptions);
}
if (servicesIds && servicesIds.length > 0) {
const servicesFilter = {
where: {id: {inq: servicesIds}}
};
const services = await models.TicketService.find(servicesFilter, myOptions);
for (const service of services) {
await models.TicketService.create({
description: service.description,
quantity: - service.quantity,
price: service.price,
taxClassFk: service.taxClassFk,
ticketFk: refundTicket.id,
ticketServiceTypeFk: service.ticketServiceTypeFk,
}, myOptions);
}
}
const query = `CALL vn.ticket_recalc(?, NULL)`;
await Self.rawSql(query, [refundTicket.id], myOptions);
if (tx) await tx.commit(); if (tx) await tx.commit();
return refundTicket; return refundsTicket[0];
} catch (e) { } catch (e) {
if (tx) await tx.rollback(); if (tx) await tx.rollback();
throw e; throw e;
} }
}; };
async function createTicketRefund(ticketId, now, refundAgencyMode, refoundZoneId, withWarehouse, myOptions) {
const models = Self.app.models;
const filter = {include: {relation: 'address'}};
const ticket = await models.Ticket.findById(ticketId, filter, myOptions);
const refundTicket = await models.Ticket.create({
clientFk: ticket.clientFk,
shipped: now,
addressFk: ticket.address().id,
agencyModeFk: refundAgencyMode.id,
nickname: ticket.address().nickname,
warehouseFk: withWarehouse ? ticket.warehouseFk : null,
companyFk: ticket.companyFk,
landed: now,
zoneFk: refoundZoneId
}, myOptions);
await models.TicketRefund.create({
refundTicketFk: refundTicket.id,
originalTicketFk: ticket.id,
}, myOptions);
return refundTicket;
}
}; };

View File

@ -3,7 +3,7 @@ const LoopBackContext = require('loopback-context');
describe('Sale refund()', () => { describe('Sale refund()', () => {
const userId = 5; const userId = 5;
const ctx = {req: {accessToken: userId}}; const ctx = {req: {accessToken: userId}, args: {}};
const activeCtx = { const activeCtx = {
accessToken: {userId}, accessToken: {userId},
}; };
@ -40,6 +40,7 @@ describe('Sale refund()', () => {
try { try {
const options = {transaction: tx}; const options = {transaction: tx};
const ticketsBefore = await models.Ticket.find({}, options);
const ticket = await models.Sale.refund(ctx, salesIds, servicesIds, withWarehouse, options); const ticket = await models.Sale.refund(ctx, salesIds, servicesIds, withWarehouse, options);
@ -61,12 +62,13 @@ describe('Sale refund()', () => {
} }
] ]
}, options); }, options);
const ticketsAfter = await models.Ticket.find({}, options);
const salesLength = refundedTicket.ticketSales().length; const salesLength = refundedTicket.ticketSales().length;
const componentsLength = refundedTicket.ticketSales()[0].components().length; const componentsLength = refundedTicket.ticketSales()[0].components().length;
expect(refundedTicket).toBeDefined(); expect(refundedTicket).toBeDefined();
expect(salesLength).toEqual(2); expect(salesLength).toEqual(1);
expect(ticketsBefore.length).toEqual(ticketsAfter.length - 2);
expect(componentsLength).toEqual(4); expect(componentsLength).toEqual(4);
await tx.rollback(); await tx.rollback();

View File

@ -77,9 +77,10 @@ module.exports = function(Self) {
if (tx) await tx.rollback(); if (tx) await tx.rollback();
throw e; throw e;
} }
if (tx) {
for (const invoiceId of invoicesIds) for (const invoiceId of invoicesIds)
await models.InvoiceOut.makePdfAndNotify(ctx, invoiceId, null); await models.InvoiceOut.makePdfAndNotify(ctx, invoiceId, null);
}
return invoicesIds; return invoicesIds;
}; };

View File

@ -96,7 +96,7 @@ module.exports = Self => {
if (address.client().type().code === 'normal' && (!agencyMode || agencyMode.code != 'refund')) { if (address.client().type().code === 'normal' && (!agencyMode || agencyMode.code != 'refund')) {
const canCreateTicket = await models.Client.canCreateTicket(args.clientId, myOptions); const canCreateTicket = await models.Client.canCreateTicket(args.clientId, myOptions);
if (!canCreateTicket) if (!canCreateTicket)
throw new UserError(`You can't create a ticket for a inactive client`); throw new UserError(`You can't create a ticket for an inactive client`);
} }
if (!args.shipped && args.landed) { if (!args.shipped && args.landed) {

View File

@ -39,7 +39,6 @@ module.exports = Self => {
try { try {
const filter = {where: {ticketFk: {inq: ticketsIds}}}; const filter = {where: {ticketFk: {inq: ticketsIds}}};
const sales = await models.Sale.find(filter, myOptions); const sales = await models.Sale.find(filter, myOptions);
const salesIds = sales.map(sale => sale.id); const salesIds = sales.map(sale => sale.id);

View File

@ -30,7 +30,7 @@ describe('ticket new()', () => {
await tx.rollback(); await tx.rollback();
} }
expect(error).toEqual(new UserError(`You can't create a ticket for a inactive client`)); expect(error).toEqual(new UserError(`You can't create a ticket for an inactive client`));
}); });
it('should throw an error if the address doesnt exist', async() => { it('should throw an error if the address doesnt exist', async() => {

View File

@ -66,7 +66,7 @@ module.exports = Self => {
const ticket = await models.Ticket.findById(id); const ticket = await models.Ticket.findById(id);
const canCreateTicket = await models.Client.canCreateTicket(ticket.clientFk); const canCreateTicket = await models.Client.canCreateTicket(ticket.clientFk);
if (!canCreateTicket) if (!canCreateTicket)
throw new UserError(`You can't create a ticket for a inactive client`); throw new UserError(`You can't create a ticket for an inactive client`);
ticketId = await cloneTicket(originalTicket, myOptions); ticketId = await cloneTicket(originalTicket, myOptions);
} }

View File

@ -12,6 +12,7 @@ module.exports = Self => {
require('../methods/sale/refund')(Self); require('../methods/sale/refund')(Self);
require('../methods/sale/canEdit')(Self); require('../methods/sale/canEdit')(Self);
require('../methods/sale/usesMana')(Self); require('../methods/sale/usesMana')(Self);
require('../methods/sale/clone')(Self);
Self.validatesPresenceOf('concept', { Self.validatesPresenceOf('concept', {
message: `Concept cannot be blank` message: `Concept cannot be blank`

View File

@ -7,6 +7,7 @@ module.exports = {
mixins: [vnReport], mixins: [vnReport],
async serverPrefetch() { async serverPrefetch() {
this.invoice = await this.findOneFromDef('invoice', [this.reference]); this.invoice = await this.findOneFromDef('invoice', [this.reference]);
this.checkMainEntity(this.invoice); this.checkMainEntity(this.invoice);
this.client = await this.findOneFromDef('client', [this.reference]); this.client = await this.findOneFromDef('client', [this.reference]);
this.taxes = await this.rawSqlFromDef(`taxes`, [this.reference]); this.taxes = await this.rawSqlFromDef(`taxes`, [this.reference]);