3038-module_transactions #713

Merged
joan merged 11 commits from 3038-module_transactions into dev 2021-08-13 15:43:41 +00:00
45 changed files with 1180 additions and 102 deletions
Showing only changes of commit d4c5d54fa8 - Show all commits

View File

@ -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');

View File

@ -0,0 +1,14 @@
create table `vn`.`itemMatchProperties`
(
itemFk int not null,
name varchar(80) not null,
producer varchar(80) not null,
size int not null,
constraint itemMatchProperties_pk
primary key (itemFk, name, producer, size),
constraint itemFk___fk
foreign key (itemFk) references item (id)
on update cascade on delete cascade
)
comment 'Propiedades para encontrar articulos equivalentes en verdnatura';

View File

@ -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 ;

View File

@ -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 ;

View File

@ -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 ;

View File

@ -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]',

View File

@ -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');
});
});

View File

@ -29,9 +29,6 @@ describe('Entry import, create and edit buys path', () => {
});
it('should fill the form, import the designated JSON file and select items for each import and confirm import', async() => {
await page.write(selectors.entryBuys.ref, 'a reference');
await page.write(selectors.entryBuys.observation, 'an observation');
let currentDir = process.cwd();
let filePath = `${currentDir}/e2e/assets/07_import_buys.json`;
@ -41,6 +38,9 @@ describe('Entry import, create and edit buys path', () => {
]);
await fileChooser.accept([filePath]);
await page.waitForTextInField(selectors.entryBuys.ref, '200573095, 200573106, 200573117, 200573506');
await page.waitForTextInField(selectors.entryBuys.observation, '729-6340 2846');
await page.autocompleteSearch(selectors.entryBuys.firstImportedItem, 'Ranged Reinforced weapon pistol 9mm');
await page.autocompleteSearch(selectors.entryBuys.secondImportedItem, 'Melee Reinforced weapon heavy shield 1x0.5m');
await page.autocompleteSearch(selectors.entryBuys.thirdImportedItem, 'Container medical box 1m');

View File

@ -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"
}

View File

@ -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"
}

View File

@ -11,11 +11,6 @@ module.exports = Self => {
description: 'The entry id',
http: {source: 'path'}
},
{
arg: 'options',
type: 'object',
description: 'Callback options',
},
{
arg: 'ref',
type: 'string',
@ -28,11 +23,11 @@ module.exports = Self => {
},
{
arg: 'buys',
type: ['Object'],
type: ['object'],
description: 'The buys',
}],
returns: {
type: ['Object'],
type: ['object'],
root: true
},
http: {
@ -41,23 +36,27 @@ module.exports = Self => {
}
});
Self.importBuys = async(ctx, id, options = {}) => {
Self.importBuys = async(ctx, id, options) => {
const conn = Self.dataSource.connector;
const args = ctx.args;
const models = Self.app.models;
let tx;
const myOptions = {};
if (!options.transaction) {
if (typeof options == 'object')
Object.assign(myOptions, options);
if (!myOptions.transaction) {
tx = await Self.beginTransaction({});
options.transaction = tx;
myOptions.transaction = tx;
}
try {
const entry = await models.Entry.findById(id, null, options);
const entry = await models.Entry.findById(id, null, myOptions);
await entry.updateAttributes({
observation: args.observation,
ref: args.ref
}, options);
}, myOptions);
const buys = [];
for (let buy of args.buys) {
@ -71,9 +70,16 @@ module.exports = Self => {
buyingValue: buy.buyingValue,
packageFk: buy.packageFk
});
await models.ItemMatchProperties.upsert({
itemFk: buy.itemFk,
name: buy.description,
producer: buy.companyName,
size: buy.size
}, myOptions);
}
const createdBuys = await models.Buy.create(buys, options);
const createdBuys = await models.Buy.create(buys, myOptions);
const buyIds = createdBuys.map(buy => buy.id);
const stmts = [];
@ -90,7 +96,7 @@ module.exports = Self => {
stmts.push('CALL buy_recalcPrices()');
const sql = ParameterizedSQL.join(stmts, ';');
await conn.executeStmt(sql, options);
await conn.executeStmt(sql, myOptions);
if (tx) await tx.commit();
} catch (e) {
if (tx) await tx.rollback();

View File

@ -37,7 +37,21 @@ module.exports = Self => {
where: {volume: {gte: buy.volume}},
order: 'volume ASC'
}, myOptions);
buy.packageFk = packaging.id;
if (packaging)
buy.packageFk = packaging.id;
const reference = await models.ItemMatchProperties.findOne({
fields: ['itemFk'],
where: {
name: buy.description,
producer: buy.companyName,
size: buy.size
}
}, myOptions);
if (reference)
buy.itemFk = reference.itemFk;
}
return buys;

View File

@ -5,6 +5,9 @@
"Buy": {
"dataSource": "vn"
},
"ItemMatchProperties": {
"dataSource": "vn"
},
"EntryLog": {
"dataSource": "vn"
},

View File

@ -0,0 +1,32 @@
{
"name": "ItemMatchProperties",
"base": "VnModel",
"options": {
"mysql": {
"table": "itemMatchProperties"
}
},
"properties": {
"itemFk": {
"type": "number",
"id": true,
"description": "Identifier"
},
"name": {
"type": "string"
},
"producer": {
"type": "string"
},
"size": {
"type": "string"
}
},
"relations": {
"item": {
"type": "belongsTo",
"model": "Item",
"foreignKey": "itemFk"
}
}
}

View File

@ -9,20 +9,6 @@
class="vn-ma-md">
<div class="vn-w-lg">
<vn-card class="vn-pa-lg">
<vn-horizontal>
<vn-textfield vn-focus
vn-one
label="Reference"
ng-model="$ctrl.import.ref">
</vn-textfield>
</vn-horizontal>
<vn-horizontal>
<vn-textarea
vn-one
label="Observation"
ng-model="$ctrl.import.observation">
</vn-textarea>
</vn-horizontal>
<vn-horizontal>
<vn-input-file
vn-one
@ -40,6 +26,20 @@
</append>
</vn-input-file>
</vn-horizontal>
<vn-horizontal ng-show="$ctrl.import.ref">
<vn-textfield vn-focus
vn-one
label="Reference"
ng-model="$ctrl.import.ref">
</vn-textfield>
</vn-horizontal>
<vn-horizontal ng-show="$ctrl.import.observation">
<vn-textarea
vn-one
label="Observation"
ng-model="$ctrl.import.observation">
</vn-textarea>
</vn-horizontal>
<vn-horizontal ng-show="$ctrl.import.buys.length > 0">
<table class="vn-table">
<thead>
@ -51,7 +51,6 @@
<th translate center>Grouping</th>
<th translate center>Buying value</th>
<th translate center>Box</th>
<th translate center>Volume</th>
</tr>
</thead>
<tbody ng-repeat="buy in $ctrl.import.buys">
@ -70,20 +69,19 @@
<tpl-item>
{{::id}} - {{::name}}
</tpl-item>
<append>
<vn-icon-button
icon="filter_alt"
vn-click-stop="$ctrl.showFilterDialog(buy)"
vn-tooltip="Filter...">
</vn-icon-button>
</append>
</vn-autocomplete>
</td>
<td title="{{::buy.description}}" expand>{{::buy.description | dashIfEmpty}}</td>
<td center title="{{::buy.size}}">{{::buy.size | dashIfEmpty}}</td>
<td center>
<vn-chip>
<span>{{::buy.packing | dashIfEmpty}}</span>
</vn-chip>
</td>
<td center>
<vn-chip>
<span>{{::buy.grouping | dashIfEmpty}}</span>
</vn-chip>
</vn-td>
<td center>{{::buy.packing | dashIfEmpty}}</td>
<td center>{{::buy.grouping | dashIfEmpty}}</td>
<td>{{::buy.buyingValue | currency: 'EUR':2}}</td>
<td center title="{{::buy.packageFk | dashIfEmpty}}">
<vn-autocomplete
@ -95,7 +93,6 @@
ng-model="buy.packageFk">
</vn-autocomplete>
</td>
<td center title="{{::buy.volume}}">{{::buy.volume | number}}</td>
</tr>
</tbody>
</table>
@ -110,7 +107,95 @@
label="Cancel"
ui-sref="entry.card.buy.index">
</vn-button>
</vn-button>
</vn-button-bar>
</div>
</form>
<vn-dialog
vn-id="filterDialog"
on-accept="$ctrl.addTime()"
message="Filter item">
<tpl-body class="itemFilter">
<vn-horizontal>
<vn-textfield
label="Name"
ng-model="$ctrl.itemFilterParams.name"
vn-focus>
</vn-textfield>
<vn-textfield
label="Size"
ng-model="$ctrl.itemFilterParams.size">
</vn-textfield>
<vn-autocomplete
label="Producer"
ng-model="$ctrl.itemFilterParams.producerFk"
url="Producers"
show-field="name"
value-field="id">
</vn-autocomplete>
<vn-autocomplete
label="Type"
ng-model="$ctrl.itemFilterParams.typeFk"
url="ItemTypes"
show-field="name"
value-field="id">
</vn-autocomplete>
<vn-autocomplete
label="Color"
ng-model="$ctrl.itemFilterParams.inkFk"
url="Inks"
show-field="name"
value-field="id">
</vn-autocomplete>
</vn-horizontal>
<vn-horizontal class="vn-mb-md">
<vn-button vn-none
label="Search"
ng-click="$ctrl.filter()">
</vn-button>
</vn-horizontal>
<vn-crud-model
vn-id="itemsModel"
url="Items"
filter="$ctrl.itemFilter"
data="items"
limit="10">
</vn-crud-model>
<vn-data-viewer
model="itemsModel"
class="vn-w-lg">
<vn-table class="scrollable">
<vn-thead>
<vn-tr>
<vn-th shrink>ID</vn-th>
<vn-th expand>Item</vn-th>
<vn-th number>Size</vn-th>
<vn-th expand>Producer</vn-th>
<vn-th>Color</vn-th>
</vn-tr>
</vn-thead>
<vn-tbody>
<a ng-repeat="item in items"
class="clickable vn-tr search-result"
ng-click="$ctrl.selectItem(item.id)">
<vn-td shrink>
<span
ng-click="itemDescriptor.show($event, item.id)"
class="link">
{{::item.id}}
</span>
</vn-td>
<vn-td expand>{{::item.name}}</vn-td>
<vn-td number>{{::item.size}}</vn-td>
<vn-td expand>{{::item.producer.name}}</vn-td>
<vn-td>{{::item.ink.name}}</vn-td>
</a>
</vn-tbody>
</vn-table>
</vn-data-viewer>
<vn-item-descriptor-popover
vn-id="item-descriptor"
warehouse-fk="$ctrl.vnConfig.warehouseFk">
</vn-item-descriptor-popover>
</tpl-body>
</vn-dialog>

View File

@ -29,6 +29,7 @@ class Controller extends Section {
this.$.$applyAsync(() => {
this.import.observation = invoice.tx_awb;
const companyName = invoice.tx_company;
const boxes = invoice.boxes;
const buys = [];
for (let box of boxes) {
@ -37,11 +38,12 @@ class Controller extends Section {
const packing = product.nu_stems_bunch * product.nu_bunches;
buys.push({
description: product.nm_product,
companyName: companyName,
size: product.nu_length,
packing: packing,
grouping: product.nu_stems_bunch,
buyingValue: parseFloat(product.mny_rate_stem),
volume: boxVolume
volume: boxVolume,
});
}
}
@ -86,6 +88,59 @@ class Controller extends Section {
? {id: $search}
: {name: {like: '%' + $search + '%'}};
}
showFilterDialog(buy) {
this.activeBuy = buy;
this.itemFilterParams = {};
this.itemFilter = {
include: [
{
relation: 'producer',
scope: {
fields: ['name']
}
},
{
relation: 'ink',
scope: {
fields: ['name']
}
}
]
};
this.$.filterDialog.show();
}
selectItem(id) {
this.activeBuy['itemFk'] = id;
this.$.filterDialog.hide();
}
filter() {
const filter = this.itemFilter;
const params = this.itemFilterParams;
const where = {};
for (let key in params) {
const value = params[key];
if (!value) continue;
switch (key) {
case 'name':
where[key] = {like: `%${value}%`};
break;
case 'producerFk':
case 'typeFk':
case 'size':
case 'ink':
where[key] = value;
}
}
filter.where = where;
this.$.itemsModel.applyFilter(filter);
}
}
Controller.$inject = ['$element', '$scope'];

View File

@ -2,4 +2,10 @@ vn-entry-buy-import {
.vn-table > tbody td:nth-child(1) {
width: 250px
}
}
.itemFilter {
vn-table.scrollable {
height: 500px
}
}

View File

@ -3,4 +3,6 @@ Observation: Observación
Box: Embalaje
Import buys: Importar compras
Some of the imported buys doesn't have an item: Algunas de las compras importadas no tienen un artículo
JSON files only: Solo ficheros JSON
JSON files only: Solo ficheros JSON
Filter item: Filtrar artículo
Filter...: Filtrar...

View File

@ -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;
}
};

View File

@ -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', () => {

View File

@ -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);

View File

@ -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);

View File

@ -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;
}
});
});

View File

@ -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;
}
});
});

View File

@ -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"');
});
});

View File

@ -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"
}
}

View File

@ -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"
}]
}

View File

@ -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);
};

View File

@ -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"
}]
}

View File

@ -7,3 +7,4 @@ import './summary';
import './card';
import './descriptor';
import './descriptor-popover';
import './index/manual';

View File

@ -57,6 +57,26 @@
</vn-table>
</vn-card>
</vn-data-viewer>
<div fixed-bottom-right>
<vn-vertical style="align-items: center;">
<vn-button class="round sm vn-mb-sm"
icon="add"
ng-click="invoicingOptions.show($event)"
vn-tooltip="Make invoice..."
tooltip-position="left"
vn-acl="invoicing"
vn-acl-action="remove">
</vn-button>
<vn-menu vn-id="invoicingOptions">
<vn-item translate
name="manualInvoice"
ng-click="manualInvoicing.show()">
Manual invoicing
</vn-item>
</vn-menu>
</vn-vertical>
</div>
<vn-popup vn-id="summary">
<vn-invoice-out-summary
invoice-out="$ctrl.selectedInvoiceOut">
@ -65,3 +85,6 @@
<vn-client-descriptor-popover
vn-id="clientDescriptor">
</vn-client-descriptor-popover>
<vn-invoice-out-manual
vn-id="manual-invoicing">
</vn-invoice-out-manual>

View File

@ -3,4 +3,5 @@ Issued: Fecha factura
Due date: Fecha vencimiento
Has PDF: PDF disponible
Minimum: Minimo
Maximum: Máximo
Maximum: Máximo
Manual invoicing: Facturación manual

View File

@ -0,0 +1,75 @@
<tpl-title translate>
Create manual invoice
</tpl-title>
<tpl-body id="manifold-form">
<vn-crud-model
auto-load="true"
url="InvoiceOutSerials"
data="invoiceOutSerials"
order="code">
</vn-crud-model>
<vn-crud-model
auto-load="true"
url="TaxAreas"
data="taxAreas"
order="code">
</vn-crud-model>
<vn-horizontal class="manifold-panel">
<vn-autocomplete
url="Tickets"
label="Ticket"
search-function="{or: [{id: $search}, {nickname: {like: '%'+$search+'%'}}]}"
show-field="nickname"
value-field="id"
ng-model="$ctrl.invoice.ticketFk"
order="shipped DESC"
on-change="$ctrl.invoice.clientFk = null">
<tpl-item>
{{::id}} - {{::nickname}}
</tpl-item>
</vn-autocomplete>
<vn-none class="or vn-px-md" translate>Or</vn-none>
<vn-autocomplete
url="Clients"
label="Client"
search-function="{or: [{id: $search}, {name: {like: '%'+$search+'%'}}]}"
show-field="name"
value-field="id"
ng-model="$ctrl.invoice.clientFk"
on-change="$ctrl.invoice.ticketFk = null">
</vn-autocomplete>
<vn-date-picker
vn-one
label="Max date"
ng-model="$ctrl.invoice.maxShipped">
</vn-date-picker>
</vn-horizontal>
<vn-horizontal>
<vn-autocomplete
data="invoiceOutSerials"
label="Serial"
show-field="description"
value-field="code"
ng-model="$ctrl.invoice.serial"
required="true">
</vn-autocomplete>
<vn-autocomplete
data="taxAreas"
label="Area"
show-field="code"
value-field="code"
ng-model="$ctrl.invoice.taxArea"
required="true">
</vn-autocomplete>
</vn-horizontal>
<vn-horizontal>
<vn-textfield
label="Reference"
ng-model="$ctrl.invoice.reference">
</vn-textfield>
</vn-horizontal>
</tpl-body>
<tpl-buttons>
<input type="button" response="cancel" translate-attr="{value: 'Cancel'}"/>
<button response="accept" translate vn-focus>Make invoice</button>
</tpl-buttons>

View File

@ -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: '<?',
clientFk: '<?'
}
});

View File

@ -0,0 +1,66 @@
import './index';
describe('InvoiceOut', () => {
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('<vn-invoice-out-manual></vn-invoice-out-manual>');
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();
});
});
});
});

View File

@ -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

View File

@ -0,0 +1,5 @@
.vn-invoice-out-manual {
tpl-body {
width: 500px
}
}

View File

@ -65,18 +65,6 @@
"Tag": {
"dataSource": "vn"
},
"TaxClass": {
"dataSource": "vn"
},
"TaxClassCode": {
"dataSource": "vn"
},
"TaxCode": {
"dataSource": "vn"
},
"TaxType": {
"dataSource": "vn"
},
"FixedPrice": {
"dataSource": "vn"
}

View File

@ -1,6 +1,6 @@
Ink: Tinta
Origin: Origen
Producer: Productor.
Producer: Productor
With visible: Con visible
Field: Campo
More fields: Más campos

View File

@ -28,30 +28,57 @@ module.exports = Self => {
});
Self.getItemTypeAvailable = async(orderId, itemCategoryId) => {
let stmts = [];
const stmts = [];
let stmt;
let order = await app.models.Order.findById(orderId);
const order = await app.models.Order.findById(orderId);
stmt = new ParameterizedSQL('call vn.available_calc(?, ?, ?)', [
order.landed,
order.addressFk,
order.agencyModeFk
]);
stmts.push(stmt);
stmt = new ParameterizedSQL(`
SELECT it.id, it.name, ic.name categoryName
stmts.push('DROP TEMPORARY TABLE IF EXISTS tmp.item');
stmt = new ParameterizedSQL(
`CREATE TEMPORARY TABLE tmp.item
(PRIMARY KEY (itemFk)) ENGINE = MEMORY
SELECT DISTINCT
i.id AS itemFk,
it.id AS typeFk,
it.name,
ic.name AS categoryName
FROM tmp.availableCalc ac
JOIN cache.available a ON a.calc_id = ac.calcFk
JOIN item i ON i.id = a.item_id
JOIN itemType it ON it.id = i.typeFk
JOIN itemCategory ic ON ic.id = it.categoryFk
WHERE it.categoryFk = ?
GROUP BY it.id`, [itemCategoryId]
JOIN vn.item i ON i.id = a.item_id
JOIN vn.itemType it ON it.id = i.typeFk
JOIN vn.itemCategory ic ON ic.id = it.categoryFk
WHERE it.categoryFk = ?`, [itemCategoryId]
);
let categoriesIndex = stmts.push(stmt) - 1;
stmts.push(stmt);
let sql = ParameterizedSQL.join(stmts, ';');
let result = await Self.rawStmt(sql);
stmt = new ParameterizedSQL(
'CALL vn.catalog_calculate(?, ?, ?)', [
order.landed,
order.addressFk,
order.agencyModeFk,
]
);
stmts.push(stmt);
stmt = new ParameterizedSQL(`
SELECT i.typeFk AS id, i.name, i.categoryName
FROM tmp.item i
JOIN tmp.ticketCalculateItem tci ON tci.itemFk = i.itemFk
GROUP BY i.typeFk`
);
const categoriesIndex = stmts.push(stmt) - 1;
stmts.push('DROP TEMPORARY TABLE tmp.item');
const sql = ParameterizedSQL.join(stmts, ';');
const result = await Self.rawStmt(sql);
return result[categoriesIndex];
};