Merge branch 'dev' of https://gitea.verdnatura.es/verdnatura/salix into 3038-module_transactions

This commit is contained in:
Carlos Jimenez Ruiz 2021-08-11 16:06:52 +02:00
commit da7bda2d0e
37 changed files with 46814 additions and 95 deletions

View File

@ -3,5 +3,7 @@ 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');
VALUES
('InvoiceOut', 'createManualInvoice', 'WRITE', 'ALLOW', 'ROLE', 'invoicing'),
('InvoiceOut', 'globalInvoicing', 'WRITE', 'ALLOW', 'ROLE', 'invoicing');

View File

@ -127,11 +127,12 @@ INSERT INTO `vn`.`warehouseAlias`(`id`, `name`)
INSERT INTO `vn`.`warehouse`(`id`, `name`, `code`, `isComparative`, `isInventory`, `hasAvailable`, `isManaged`, `hasStowaway`, `hasDms`, `hasComission`, `aliasFk`, `countryFk`)
VALUES
(1, 'Warehouse One', 'ALG', 1, 1, 1, 1, 1, 1, 1, 2, 1),
(2, 'Warehouse Two', NULL, 1, 1, 1, 1, 0, 0, 1, 2, 13),
(3, 'Warehouse Three', NULL, 1, 1, 1, 1, 0, 0, 0, 2, 1),
(4, 'Warehouse Four', NULL, 1, 1, 1, 1, 0, 0, 0, 2, 1),
(5, 'Warehouse Five', NULL, 1, 1, 1, 1, 0, 0, 0, 2, 1);
(1, 'Warehouse One', 'ALG', 1, 1, 1, 1, 1, 1, 1, 2, 1),
(2, 'Warehouse Two', NULL, 1, 1, 1, 1, 0, 0, 1, 2, 13),
(3, 'Warehouse Three', NULL, 1, 1, 1, 1, 0, 0, 0, 2, 1),
(4, 'Warehouse Four', NULL, 1, 1, 1, 1, 0, 0, 0, 2, 1),
(5, 'Warehouse Five', NULL, 1, 1, 1, 1, 0, 0, 0, 2, 1),
(13, 'Inventory', NULL, 1, 1, 1, 0, 0, 0, 0, 2, 1);
INSERT INTO `vn`.`sector`(`id`, `description`, `warehouseFk`, `isPreviousPreparedByPacking`, `code`, `pickingPlacement`, `path`)
VALUES

View File

@ -917,13 +917,17 @@ export default {
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"]',
createGlobalInvoice: 'vn-item[name="globalInvoice"]',
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"]'
saveInvoice: 'button[response="accept"]',
globalInvoiceForm: '.vn-invoice-out-global-invoicing',
globalInvoiceDate: '[ng-model="$ctrl.invoice.invoiceDate"]',
globalInvoiceFromClient: '[ng-model="$ctrl.invoice.fromClientId"]',
globalInvoiceToClient: '[ng-model="$ctrl.invoice.toClientId"]',
},
invoiceOutDescriptor: {
moreMenu: 'vn-invoice-out-descriptor vn-icon-button[icon=more_vert]',

View File

@ -25,7 +25,7 @@ describe('InvoiceOut manual invoice path', () => {
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);
await page.waitToClick(selectors.invoiceOutIndex.saveInvoice);
const message = await page.waitForSnackbar();
expect(message.text).toContain('Data saved!');
@ -53,7 +53,7 @@ describe('InvoiceOut manual invoice path', () => {
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);
await page.waitToClick(selectors.invoiceOutIndex.saveInvoice);
const message = await page.waitForSnackbar();
expect(message.text).toContain('Data saved!');

View File

@ -0,0 +1,51 @@
import selectors from '../../helpers/selectors.js';
import getBrowser from '../../helpers/puppeteer';
describe('InvoiceOut global invoice path', () => {
let browser;
let page;
beforeAll(async() => {
browser = await getBrowser();
page = browser.page;
await page.loginAndModule('administrative', 'invoiceOut');
await page.waitToClick('[icon="search"]');
await page.waitForTimeout(1000); // index search needs time to return results
});
afterAll(async() => {
await browser.close();
});
let invoicesBefore;
it('should count the amount of invoices listed before globla invoces are made', async() => {
invoicesBefore = await page.countElement(selectors.invoiceOutIndex.searchResult);
expect(invoicesBefore).toBeGreaterThanOrEqual(4);
});
it('should open the global invoice form', async() => {
await page.waitToClick(selectors.invoiceOutIndex.createInvoice);
await page.waitToClick(selectors.invoiceOutIndex.createGlobalInvoice);
await page.waitForSelector(selectors.invoiceOutIndex.globalInvoiceForm);
});
it('should create a global invoice for charles xavier today', async() => {
await page.pickDate(selectors.invoiceOutIndex.globalInvoiceDate);
await page.autocompleteSearch(selectors.invoiceOutIndex.globalInvoiceFromClient, 'Petter Parker');
await page.autocompleteSearch(selectors.invoiceOutIndex.globalInvoiceToClient, 'Petter Parker');
await page.waitToClick(selectors.invoiceOutIndex.saveInvoice);
const message = await page.waitForSnackbar();
expect(message.text).toContain('Data saved!');
});
it('should count the amount of invoices listed after globla invocing', async() => {
await page.waitToClick('[icon="search"]');
await page.waitForTimeout(1000); // index search needs time to return results
const currentInvoices = await page.countElement(selectors.invoiceOutIndex.searchResult);
expect(currentInvoices).toBeGreaterThan(invoicesBefore);
});
});

View File

@ -14,6 +14,7 @@
"angular-animate": "^1.7.8",
"angular-translate": "^2.18.1",
"angular-translate-loader-partial": "^2.18.1",
"croppie": "^2.6.5",
"js-yaml": "^3.13.1",
"mg-crud": "^1.1.2",
"oclazyload": "^0.6.3",
@ -77,6 +78,11 @@
"sprintf-js": "~1.0.2"
}
},
"node_modules/croppie": {
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/croppie/-/croppie-2.6.5.tgz",
"integrity": "sha512-IlChnVUGG5T3w2gRZIaQgBtlvyuYnlUWs2YZIXXR3H9KrlO1PtBT3j+ykxvy9eZIWhk+V5SpBmhCQz5UXKrEKQ=="
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
@ -200,6 +206,11 @@
"sprintf-js": "~1.0.2"
}
},
"croppie": {
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/croppie/-/croppie-2.6.5.tgz",
"integrity": "sha512-IlChnVUGG5T3w2gRZIaQgBtlvyuYnlUWs2YZIXXR3H9KrlO1PtBT3j+ykxvy9eZIWhk+V5SpBmhCQz5UXKrEKQ=="
},
"esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",

View File

@ -14,6 +14,7 @@
"angular-animate": "^1.7.8",
"angular-translate": "^2.18.1",
"angular-translate-loader-partial": "^2.18.1",
"croppie": "^2.6.5",
"js-yaml": "^3.13.1",
"mg-crud": "^1.1.2",
"oclazyload": "^0.6.3",

View File

@ -0,0 +1,252 @@
@import "./variables";
.croppie-container {
width: 100%;
height: 100%;
}
.croppie-container .cr-image {
z-index: -1;
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
max-height: none;
max-width: none;
}
.croppie-container .cr-boundary {
border: 2px solid $color-primary;
position: relative;
overflow: hidden;
margin: 0 auto;
z-index: 1;
width: 100%;
height: 100%;
}
.croppie-container .cr-viewport,
.croppie-container .cr-resizer {
position: absolute;
border: 2px solid #fff;
margin: auto;
top: 0;
bottom: 0;
right: 0;
left: 0;
box-shadow: 0 0 2000px 2000px rgba(0, 0, 0, 0.5);
z-index: 0;
}
.croppie-container .cr-resizer {
z-index: 2;
box-shadow: none;
pointer-events: none;
}
.croppie-container .cr-resizer-vertical,
.croppie-container .cr-resizer-horisontal {
position: absolute;
pointer-events: all;
}
.croppie-container .cr-resizer-vertical::after,
.croppie-container .cr-resizer-horisontal::after {
display: block;
position: absolute;
box-sizing: border-box;
border: 1px solid black;
background: #fff;
width: 10px;
height: 10px;
content: '';
}
.croppie-container .cr-resizer-vertical {
bottom: -5px;
cursor: row-resize;
width: 100%;
height: 10px;
}
.croppie-container .cr-resizer-vertical::after {
left: 50%;
margin-left: -5px;
}
.croppie-container .cr-resizer-horisontal {
right: -5px;
cursor: col-resize;
width: 10px;
height: 100%;
}
.croppie-container .cr-resizer-horisontal::after {
top: 50%;
margin-top: -5px;
}
.croppie-container .cr-original-image {
display: none;
}
.croppie-container .cr-vp-circle {
border-radius: 50%;
}
.croppie-container .cr-overlay {
z-index: 1;
position: absolute;
cursor: move;
touch-action: none;
}
.croppie-container .cr-slider-wrap {
margin: 15px auto;
text-align: center;
}
.croppie-result {
position: relative;
overflow: hidden;
}
.croppie-result img {
position: absolute;
}
.croppie-container .cr-image,
.croppie-container .cr-overlay,
.croppie-container .cr-viewport {
-webkit-transform: translateZ(0);
-moz-transform: translateZ(0);
-ms-transform: translateZ(0);
transform: translateZ(0);
}
/*************************************/
/***** STYLING RANGE INPUT ***********/
/*************************************/
/*http://brennaobrien.com/blog/2014/05/style-input-type-range-in-every-browser.html */
/*************************************/
.cr-slider {
-webkit-appearance: none;
/*removes default webkit styles*/
/*border: 1px solid white; *//*fix for FF unable to apply focus style bug */
width: 300px;
/*required for proper track sizing in FF*/
max-width: 100%;
padding-top: 8px;
padding-bottom: 8px;
background-color: transparent;
}
.cr-slider::-webkit-slider-runnable-track {
width: 100%;
height: 3px;
background: rgba(0, 0, 0, 0.2);
border: 0;
}
.cr-slider::-webkit-slider-thumb {
-webkit-appearance: none;
border: none;
height: 16px;
width: 16px;
border-radius: 50%;
background: $color-primary;
margin-top: -6px;
}
.cr-slider:focus {
outline: none;
}
/*
.cr-slider:focus::-webkit-slider-runnable-track {
background: #ccc;
}
*/
.cr-slider::-moz-range-track {
width: 100%;
height: 3px;
background: rgba(0, 0, 0, 0.5);
border: 0;
border-radius: 3px;
}
.cr-slider::-moz-range-thumb {
border: none;
height: 16px;
width: 16px;
border-radius: 50%;
background: #ddd;
margin-top: -6px;
}
/*hide the outline behind the border*/
.cr-slider:-moz-focusring {
outline: 1px solid white;
outline-offset: -1px;
}
.cr-slider::-ms-track {
width: 100%;
height: 5px;
background: transparent;
/*remove bg colour from the track, we'll use ms-fill-lower and ms-fill-upper instead */
border-color: transparent;/*leave room for the larger thumb to overflow with a transparent border */
border-width: 6px 0;
color: transparent;/*remove default tick marks*/
}
.cr-slider::-ms-fill-lower {
background: rgba(0, 0, 0, 0.5);
border-radius: 10px;
}
.cr-slider::-ms-fill-upper {
background: rgba(0, 0, 0, 0.5);
border-radius: 10px;
}
.cr-slider::-ms-thumb {
border: none;
height: 16px;
width: 16px;
border-radius: 50%;
background: #ddd;
margin-top:1px;
}
.cr-slider:focus::-ms-fill-lower {
background: rgba(0, 0, 0, 0.5);
}
.cr-slider:focus::-ms-fill-upper {
background: rgba(0, 0, 0, 0.5);
}
/*******************************************/
/***********************************/
/* Rotation Tools */
/***********************************/
.cr-rotate-controls {
position: absolute;
bottom: 5px;
left: 5px;
z-index: 1;
}
.cr-rotate-controls button {
border: 0;
background: none;
}
.cr-rotate-controls i:before {
display: inline-block;
font-style: normal;
font-weight: 900;
font-size: 22px;
}
.cr-rotate-l i:before {
content: '';
}
.cr-rotate-r i:before {
content: '';
}

View File

@ -1,36 +1,62 @@
<vn-dialog class="edit"
vn-id="dialog"
on-accept="$ctrl.onUploadAccept()"
message="Upload new photo">
message="Edit photo">
<tpl-body class="upload-photo">
<vn-horizontal ng-show="file.value" class="photo vn-mb-md">
<div><img vn-id="photo" ng-src=""/></div>
</vn-horizontal>
<vn-horizontal>
<vn-textfield
vn-one
label="File name"
ng-model="$ctrl.newPhoto.fileName"
required="true">
</vn-input-file>
</vn-horizontal>
<vn-horizontal>
<vn-input-file vn-id="file"
vn-one
label="File"
ng-model="$ctrl.newPhoto.files"
on-change="$ctrl.updatePhotoPreview(value)"
accept="{{$ctrl.allowedContentTypes}}"
required="true">
<append>
<vn-icon vn-none
color-marginal
title="{{$ctrl.contentTypesInfo}}"
icon="info">
</vn-icon>
</append>
</vn-input-file>
</vn-horizontal>
<vn-horizontal>
<vn-one ng-if="file.value">
<vn-horizontal>
<vn-icon-button vn-none
icon="rotate_left"
vn-tooltip="Rotate left"
ng-click="$ctrl.rotateLeft()">
</vn-icon-button>
<div id="photoContainer"></div>
<vn-icon-button vn-none
icon="rotate_right"
vn-tooltip="Rotate right"
ng-click="$ctrl.rotateRight()">
</vn-icon-button>
</vn-horizontal>
</vn-one>
<vn-one>
<vn-horizontal>
<vn-input-file vn-id="file"
vn-one
label="File"
ng-model="$ctrl.newPhoto.files"
on-change="$ctrl.updatePhotoPreview(value)"
accept="{{$ctrl.allowedContentTypes}}"
required="true">
<append>
<vn-icon vn-none
color-marginal
title="{{$ctrl.contentTypesInfo}}"
icon="info">
</vn-icon>
</append>
</vn-input-file>
</vn-horizontal>
<vn-horizontal>
<vn-autocomplete
label="Type"
ng-model="$ctrl.viewportType"
data="$ctrl.viewportTypes"
selection="$ctrl.viewportSelection"
show-field="description"
value-field="code">
</vn-autocomplete>
</vn-horizontal>
<vn-horizontal>
<vn-textfield
vn-one
label="File name"
ng-model="$ctrl.newPhoto.fileName"
required="true">
</vn-input-file>
</vn-horizontal>
</vn-one>
</vn-horizontal>
</tpl-body>
<tpl-buttons>
<input type="button" response="cancel" translate-attr="{value: 'Cancel'}"/>

View File

@ -1,24 +1,74 @@
import ngModule from '../../module';
import Component from 'core/lib/component';
import Croppie from 'croppie';
import './style.scss';
import './croppie.scss';
/**
* Small card with basing entity information and actions.
*/
export default class UploadPhoto extends Component {
constructor($element, $) {
super($element, $);
this.viewportTypes = [
{
code: 'normal',
description: this.$t('Normal'),
viewport: {
width: 400,
height: 400
},
output: {
width: 1200,
height: 1200
}
},
{
code: 'panoramic',
description: this.$t('Panoramic'),
viewport: {
width: 675,
height: 450
},
output: {
width: 1350,
height: 900
}
}
];
this.viewportType = 'normal';
this.getAllowedContentTypes();
}
/**
* Opens the dialog and sets the default data
* @param {*} collection - Collection name
* @param {*} id - Entity id
*/
show(collection, id) {
this.editor = null;
this.newPhoto = {
id: id,
collection: collection,
fileName: id
};
this.$.dialog.show();
this.getAllowedContentTypes();
}
get viewportSelection() {
return this._viewportSelection;
}
set viewportSelection(value) {
this._viewportSelection = value;
if (value && this.newPhoto.files) {
this.displayEditor();
const files = this.newPhoto.files;
this.updatePhotoPreview(files);
}
}
getAllowedContentTypes() {
@ -41,12 +91,39 @@ export default class UploadPhoto extends Component {
*/
updatePhotoPreview(value) {
if (value && value[0]) {
if (!this.editor)
this.displayEditor();
const reader = new FileReader();
reader.onload = e => this.$.photo.src = e.target.result;
reader.onload = e => this.editor.bind({url: e.target.result});
reader.readAsDataURL(value[0]);
}
}
displayEditor() {
const viewportType = this.viewportSelection;
const viewport = viewportType.viewport;
const boundaryWidth = viewport.width + 200;
const boundaryHeight = viewport.height + 200;
const container = document.getElementById('photoContainer');
if (this.editor) this.editor.destroy();
this.editor = new Croppie(container, {
viewport: {width: viewport.width, height: viewport.height},
boundary: {width: boundaryWidth, height: boundaryHeight},
enableOrientation: true,
showZoomer: true
});
}
rotateLeft() {
this.editor.rotate(90);
}
rotateRight() {
this.editor.rotate(-90);
}
/**
* Dialog response handler
*
@ -57,12 +134,22 @@ export default class UploadPhoto extends Component {
if (!this.newPhoto.files)
throw new Error(`Select an image`);
this.makeRequest();
const viewportType = this.viewportSelection;
const output = viewportType.output;
const options = {
type: 'blob',
size: {
width: output.width,
height: output.height
}
};
return this.editor.result(options)
.then(blob => this.newPhoto.blob = blob)
.then(() => this.makeRequest());
} catch (e) {
this.vnApp.showError(this.$t(e.message));
return false;
}
return true;
}
/**
@ -79,10 +166,13 @@ export default class UploadPhoto extends Component {
params: this.newPhoto,
headers: {'Content-Type': undefined},
timeout: this.canceler.promise,
transformRequest: files => {
transformRequest: ([file]) => {
const formData = new FormData();
for (let i = 0; i < files.length; i++)
formData.append(files[i].name, files[i]);
const now = new Date();
const timestamp = now.getTime();
const fileName = `${file.name}_${timestamp}`;
formData.append('blob', this.newPhoto.blob, fileName);
return formData;
},

View File

@ -6,7 +6,9 @@ describe('Salix', () => {
let $scope;
let $httpBackend;
beforeEach(ngModule('salix'));
beforeEach(ngModule('salix', $translateProvider => {
$translateProvider.translations('en', {});
}));
beforeEach(inject(($componentController, $rootScope, _$httpBackend_) => {
$scope = $rootScope.$new();
@ -14,12 +16,58 @@ describe('Salix', () => {
const $element = angular.element('<vn-upload-photo></vn-upload-photo>');
controller = $componentController('vnUploadPhoto', {$element, $scope});
controller.newPhoto = {};
controller.$t = m => m;
}));
afterEach(() => {
$scope.$destroy();
});
describe('viewportSelection()', () => {
it('should call to displayEditor() and updatePhotoPreview() methods', () => {
controller.displayEditor = jest.fn();
controller.updatePhotoPreview = jest.fn();
const files = [{name: 'test.jpg'}];
controller.newPhoto.files = files;
controller.viewportSelection = {code: 'normal'};
expect(controller.displayEditor).toHaveBeenCalledWith();
expect(controller.updatePhotoPreview).toHaveBeenCalledWith(files);
});
});
describe('displayEditor()', () => {
it('should define the editor property', () => {
controller.viewportSelection = {
code: 'normal',
description: 'Normal',
viewport: {
width: 400,
height: 400
},
output: {
width: 1200,
height: 1200
}
};
const element = document.createElement('div');
jest.spyOn(document, 'getElementById').mockReturnValue(element);
controller.displayEditor();
const editor = controller.editor;
expect(editor).toBeDefined();
expect(editor.options.viewport.width).toEqual(400);
expect(editor.options.viewport.width).toEqual(400);
expect(editor.options.boundary.width).toEqual(600);
expect(editor.options.boundary.height).toEqual(600);
});
});
describe('onUploadAccept()', () => {
it('should throw an error message containing "Select an image"', () => {
jest.spyOn(controller.vnApp, 'showError');
@ -29,13 +77,33 @@ describe('Salix', () => {
expect(controller.vnApp.showError).toHaveBeenCalledWith('Select an image');
});
it('should call to the makeRequest() method', () => {
it('should call to the makeRequest() method', done => {
controller.editor = {
result: () => {}
};
jest.spyOn(controller, 'makeRequest');
jest.spyOn(controller.editor, 'result').mockReturnValue(new Promise(resolve => resolve('blobFile')));
controller.viewportSelection = {
code: 'normal',
description: 'Normal',
viewport: {
width: 400,
height: 400
},
output: {
width: 1200,
height: 1200
}
};
controller.newPhoto.files = [0];
controller.onUploadAccept();
expect(controller.makeRequest).toHaveBeenCalledWith();
controller.onUploadAccept().then(() => {
expect(controller.newPhoto.blob).toEqual('blobFile');
expect(controller.makeRequest).toHaveBeenCalledWith();
done();
}).catch(done.fail);
});
});
@ -44,7 +112,11 @@ describe('Salix', () => {
jest.spyOn(controller.vnApp, 'showSuccess');
jest.spyOn(controller, 'emit');
$httpBackend.expectGET('ImageContainers/allowedContentTypes').respond(200, ['image/jpg']);
controller.newPhoto.files = [{name: 'hola'}];
controller.newPhoto.blob = new Blob([]);
$httpBackend.expectRoute('POST', 'Images/upload').respond(200);
controller.makeRequest();
$httpBackend.flush();

View File

@ -1,3 +1,6 @@
Upload new photo: Subir una nueva foto
Edit photo: Editar foto
Select an image: Selecciona una imagen
File name: Nombre del fichero
File name: Nombre del fichero
Rotate left: Girar a la izquierda
Rotate right: Girar a la derecha
Panoramic: Panorámico

View File

@ -1,26 +1,11 @@
@import "./variables";
.upload-photo {
.photo {
position: relative;
margin: 0 auto;
text-align: center;
& > div {
border: 3px solid $color-primary;
max-width: 256px;
max-height: 256px;
border-radius: 50%;
overflow: hidden
}
& > div > img[ng-src] {
width: 256px;
height: 256px;
display: block
}
& > vn-horizontal {
align-items: initial;
}
& > vn-spinner {
display: block;
height: 40px;
@ -28,10 +13,13 @@
}
vn-input-file {
max-width: 256px;
div.control {
overflow: hidden
}
}
.form {
align-items: initial;
}
}

View File

@ -105,11 +105,13 @@
"Client assignment has changed": "I did change the salesperson ~*\"<{{previousWorkerName}}>\"*~ by *\"<{{currentWorkerName}}>\"* from the client [{{clientName}} ({{clientId}})]({{{url}}})",
"None": "None",
"error densidad = 0": "error densidad = 0",
"nickname": "nickname",
"This document already exists on this ticket": "This document already exists on this ticket",
"serial non editable": "This serial doesn't allow to set a reference",
"nickname": "nickname",
"State": "State",
"regular": "regular",
"reserved": "reserved",
"Global invoicing failed": "[Global invoicing] Wasn't able to invoice some of the clients",
"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

@ -202,5 +202,7 @@
"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"
"A ticket with a negative base can't be invoiced": "No se puede facturar un ticket con una base negativa",
"Global invoicing failed": "[Facturación global] No se han podido facturar algunos clientes",
"Wasn't able to invoice the following clients": "No se han podido facturar los siguientes clientes"
}

View File

@ -138,11 +138,12 @@ module.exports = Self => {
}
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();
if (newInvoice.id)
await Self.createPdf(ctx, newInvoice.id);
return newInvoice;
} catch (e) {
if (tx) await tx.rollback();

View File

@ -71,18 +71,20 @@ module.exports = Self => {
await fs.mkdir(src, {recursive: true});
if (tx) await tx.commit();
const writeStream = fs.createWriteStream(fileSrc);
writeStream.on('open', () => {
response.pipe(writeStream);
});
writeStream.on('finish', async function() {
writeStream.end();
return new Promise(resolve => {
writeStream.on('finish', () => {
writeStream.end();
resolve(invoiceOut);
});
});
if (tx) await tx.commit();
return invoiceOut;
} catch (e) {
if (tx) await tx.rollback();
if (fs.existsSync(fileSrc))

View File

@ -0,0 +1,263 @@
const UserError = require('vn-loopback/util/user-error');
module.exports = Self => {
Self.remoteMethodCtx('globalInvoicing', {
description: 'Make a global invoice',
accessType: 'WRITE',
accepts: [
{
arg: 'invoiceDate',
type: 'date',
description: 'The invoice date'
},
{
arg: 'maxShipped',
type: 'date',
description: 'The maximum shipped date'
},
{
arg: 'fromClientId',
type: 'number',
description: 'The minimum client id'
},
{
arg: 'toClientId',
type: 'number',
description: 'The maximum client id'
},
{
arg: 'companyFk',
type: 'number',
description: 'The company id to invoice'
}
],
returns: {
type: 'object',
root: true
},
http: {
path: '/globalInvoicing',
verb: 'POST'
}
});
Self.globalInvoicing = async(ctx, options) => {
const args = ctx.args;
const invoicesIds = [];
const failedClients = [];
let tx;
const myOptions = {};
if (typeof options == 'object')
Object.assign(myOptions, options);
if (!myOptions.transaction) {
tx = await Self.beginTransaction({});
myOptions.transaction = tx;
}
let query;
try {
query = `
SELECT MAX(issued) issued
FROM vn.invoiceOut io
JOIN vn.time t ON t.dated = io.issued
WHERE io.serial = 'A'
AND t.year = YEAR(?)
AND io.companyFk = ?`;
const [maxIssued] = await Self.rawSql(query, [
args.invoiceDate,
args.companyFk
], myOptions);
const maxSerialDate = maxIssued.issued || args.invoiceDate;
if (args.invoiceDate < maxSerialDate)
args.invoiceDate = maxSerialDate;
if (args.invoiceDate < args.maxShipped)
args.maxShipped = args.invoiceDate;
const minShipped = new Date();
minShipped.setFullYear(minShipped.getFullYear() - 1);
// Packaging liquidation
const vIsAllInvoiceable = false;
const clientsWithPackaging = await getClientsWithPackaging(ctx, myOptions);
for (let client of clientsWithPackaging) {
await Self.rawSql('CALL packageInvoicing(?, ?, ?, ?, @newTicket)', [
client.id,
args.invoiceDate,
args.companyFk,
vIsAllInvoiceable
], myOptions);
}
const invoiceableClients = await getInvoiceableClients(ctx, myOptions);
if (!invoiceableClients.length) return;
for (let client of invoiceableClients) {
try {
if (client.hasToInvoiceByAddress) {
await Self.rawSql('CALL ticketToInvoiceByAddress(?, ?, ?, ?)', [
minShipped,
args.maxShipped,
client.addressFk,
args.companyFk
], myOptions);
} else {
await Self.rawSql('CALL invoiceFromClient(?, ?, ?)', [
args.maxShipped,
client.id,
args.companyFk
], myOptions);
}
// Make invoice
const isSpanishCompany = await getIsSpanishCompany(args.companyFk, myOptions);
// Validates ticket nagative base
const hasAnyNegativeBase = await getNegativeBase(myOptions);
if (hasAnyNegativeBase && isSpanishCompany)
continue;
query = `SELECT invoiceSerial(?, ?, ?) AS serial`;
const [invoiceSerial] = await Self.rawSql(query, [
client.id,
args.companyFk,
'G'
], myOptions);
const serialLetter = invoiceSerial.serial;
query = `CALL invoiceOut_new(?, ?, NULL, @invoiceId)`;
await Self.rawSql(query, [
serialLetter,
args.invoiceDate
], myOptions);
const [newInvoice] = await Self.rawSql(`SELECT @invoiceId id`, null, myOptions);
if (newInvoice.id) {
await Self.rawSql('CALL invoiceOutBooking(?)', [newInvoice.id], myOptions);
invoicesIds.push(newInvoice.id);
}
} catch (e) {
failedClients.push({
id: client.id,
stacktrace: e
});
continue;
}
}
if (failedClients.length > 0)
await notifyFailures(ctx, failedClients, myOptions);
if (tx) await tx.commit();
// Print invoices PDF
for (let invoiceId of invoicesIds)
await Self.createPdf(ctx, invoiceId);
return invoicesIds;
} catch (e) {
if (tx) await tx.rollback();
throw e;
}
};
async function getNegativeBase(options) {
const models = Self.app.models;
const query = 'SELECT hasAnyNegativeBase() AS base';
const [result] = await models.InvoiceOut.rawSql(query, null, options);
return result && result.base;
}
async function getIsSpanishCompany(companyId, options) {
const models = Self.app.models;
const query = `SELECT COUNT(*) AS total
FROM supplier s
JOIN country c ON c.id = s.countryFk
AND c.code = 'ES'
WHERE s.id = ?`;
const [supplierCompany] = await models.InvoiceOut.rawSql(query, [
companyId
], options);
return supplierCompany && supplierCompany.total;
}
async function getClientsWithPackaging(ctx, options) {
const models = Self.app.models;
const args = ctx.args;
const query = `SELECT DISTINCT clientFk AS id
FROM ticket t
JOIN ticketPackaging tp ON t.id = tp.ticketFk
WHERE t.shipped BETWEEN '2017-11-21' AND ?
AND t.clientFk BETWEEN ? AND ?`;
return models.InvoiceOut.rawSql(query, [
args.maxShipped,
args.fromClientId,
args.toClientId
], options);
}
async function getInvoiceableClients(ctx, options) {
const models = Self.app.models;
const args = ctx.args;
const minShipped = new Date();
minShipped.setFullYear(minShipped.getFullYear() - 1);
const query = `SELECT
c.id,
SUM(IFNULL(s.quantity * s.price * (100-s.discount)/100, 0) + IFNULL(ts.quantity * ts.price,0)) AS sumAmount,
c.hasToInvoiceByAddress,
c.email,
c.isToBeMailed,
a.id addressFk
FROM ticket t
LEFT JOIN sale s ON s.ticketFk = t.id
LEFT JOIN ticketService ts ON ts.ticketFk = t.id
JOIN address a ON a.id = t.addressFk
JOIN client c ON c.id = t.clientFk
WHERE ISNULL(t.refFk) AND c.id BETWEEN ? AND ?
AND t.shipped BETWEEN ? AND util.dayEnd(?)
AND t.companyFk = ? AND c.hasToInvoice
AND c.isTaxDataChecked
GROUP BY c.id, IF(c.hasToInvoiceByAddress,a.id,TRUE) HAVING sumAmount > 0`;
return models.InvoiceOut.rawSql(query, [
args.fromClientId,
args.toClientId,
minShipped,
args.maxShipped,
args.companyFk
], options);
}
async function notifyFailures(ctx, failedClients, options) {
const models = Self.app.models;
const userId = ctx.req.accessToken.userId;
const $t = ctx.req.__; // $translate
const worker = await models.EmailUser.findById(userId, null, options);
const subject = $t('Global invoicing failed');
let body = $t(`Wasn't able to invoice the following clients`) + ':<br/><br/>';
for (client of failedClients) {
body += `ID: <strong>${client.id}</strong>
<br/> <strong>${client.stacktrace}</strong><br/><br/>`;
}
await Self.rawSql(`
INSERT INTO vn.mail (sender, replyTo, sent, subject, body)
VALUES (?, ?, FALSE, ?, ?)`, [
worker.email,
worker.email,
subject,
body
], options);
}
};

View File

@ -11,7 +11,7 @@ describe('InvoiceOut createManualInvoice()', () => {
const ctx = {req: activeCtx};
it('should throw an error trying to invoice again', async() => {
spyOn(models.InvoiceOut, 'createPdf').and.returnValue(true);
spyOn(models.InvoiceOut, 'createPdf').and.returnValue(new Promise(resolve => resolve(true)));
const tx = await models.InvoiceOut.beginTransaction({});
const options = {transaction: tx};
@ -36,7 +36,7 @@ describe('InvoiceOut createManualInvoice()', () => {
});
it('should throw an error for a ticket with an amount of zero', async() => {
spyOn(models.InvoiceOut, 'createPdf').and.returnValue(true);
spyOn(models.InvoiceOut, 'createPdf').and.returnValue(new Promise(resolve => resolve(true)));
spyOn(LoopBackContext, 'getCurrentContext').and.returnValue({
active: activeCtx
});
@ -68,7 +68,7 @@ describe('InvoiceOut createManualInvoice()', () => {
});
it('should throw an error when the clientFk property is set without the max shipped date', async() => {
spyOn(models.InvoiceOut, 'createPdf').and.returnValue(true);
spyOn(models.InvoiceOut, 'createPdf').and.returnValue(new Promise(resolve => resolve(true)));
const tx = await models.InvoiceOut.beginTransaction({});
const options = {transaction: tx};
@ -92,7 +92,7 @@ describe('InvoiceOut createManualInvoice()', () => {
});
it('should throw an error for a non-invoiceable client', async() => {
spyOn(models.InvoiceOut, 'createPdf').and.returnValue(true);
spyOn(models.InvoiceOut, 'createPdf').and.returnValue(new Promise(resolve => resolve(true)));
const tx = await models.InvoiceOut.beginTransaction({});
const options = {transaction: tx};
@ -121,7 +121,7 @@ describe('InvoiceOut createManualInvoice()', () => {
});
it('should create a manual invoice', async() => {
spyOn(models.InvoiceOut, 'createPdf').and.returnValue(true);
spyOn(models.InvoiceOut, 'createPdf').and.returnValue(new Promise(resolve => resolve(true)));
const tx = await models.InvoiceOut.beginTransaction({});
const options = {transaction: tx};

View File

@ -1,5 +1,6 @@
const models = require('vn-loopback/server/server').models;
const got = require('got');
const fs = require('fs-extra');
describe('InvoiceOut createPdf()', () => {
const userId = 1;
@ -18,6 +19,14 @@ describe('InvoiceOut createPdf()', () => {
on: () => {},
};
spyOn(got, 'stream').and.returnValue(response);
spyOn(models.InvoiceContainer, 'container').and.returnValue({
client: {root: '/path'}
});
spyOn(fs, 'mkdir').and.returnValue(true);
spyOn(fs, 'createWriteStream').and.returnValue({
on: (event, cb) => cb(),
end: () => {}
});
const tx = await models.InvoiceOut.beginTransaction({});
const options = {transaction: tx};

View File

@ -1,7 +1,14 @@
const models = require('vn-loopback/server/server').models;
const fs = require('fs-extra');
describe('InvoiceOut download()', () => {
it('should return the downloaded fine name', async() => {
spyOn(models.InvoiceContainer, 'container').and.returnValue({
client: {root: '/path'}
});
spyOn(fs, 'createReadStream').and.returnValue(new Promise(resolve => resolve('streamObject')));
spyOn(fs, 'access').and.returnValue(true);
const result = await models.InvoiceOut.download(1);
expect(result[1]).toEqual('application/pdf');

View File

@ -0,0 +1,40 @@
const models = require('vn-loopback/server/server').models;
describe('InvoiceOut globalInvoicing()', () => {
const userId = 1;
const companyFk = 442;
const clientId = 1101;
const invoicedTicketId = 8;
const invoiceSerial = 'A';
const activeCtx = {
accessToken: {userId: userId},
};
const ctx = {req: activeCtx};
it('should make a global invoicing', async() => {
spyOn(models.InvoiceOut, 'createPdf').and.returnValue(new Promise(resolve => resolve(true)));
const tx = await models.InvoiceOut.beginTransaction({});
const options = {transaction: tx};
try {
ctx.args = {
invoiceDate: new Date(),
maxShipped: new Date(),
fromClientId: clientId,
toClientId: clientId,
companyFk: companyFk
};
const result = await models.InvoiceOut.globalInvoicing(ctx, options);
const ticket = await models.Ticket.findById(invoicedTicketId, null, options);
expect(result.length).toBeGreaterThan(0);
expect(ticket.refFk).toContain(invoiceSerial);
await tx.rollback();
} catch (e) {
await tx.rollback();
throw e;
}
});
});

View File

@ -7,4 +7,5 @@ module.exports = Self => {
require('../methods/invoiceOut/book')(Self);
require('../methods/invoiceOut/createPdf')(Self);
require('../methods/invoiceOut/createManualInvoice')(Self);
require('../methods/invoiceOut/globalInvoicing')(Self);
};

View File

@ -8,3 +8,4 @@ import './card';
import './descriptor';
import './descriptor-popover';
import './index/manual';
import './index/global-invoicing';

View File

@ -0,0 +1,70 @@
<tpl-title translate>
Create global 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="Companies"
data="companies"
order="code">
</vn-crud-model>
<div
class="progress vn-my-md"
ng-if="$ctrl.isInvoicing">
<vn-horizontal>
<vn-icon vn-none icon="warning"></vn-icon>
<span vn-none translate>Invoicing in progress...</span>
</vn-horizontal>
</div>
<vn-horizontal>
<vn-date-picker
vn-one
label="Invoice date"
ng-model="$ctrl.invoice.invoiceDate">
</vn-date-picker>
<vn-date-picker
vn-one
label="Max date"
ng-model="$ctrl.invoice.maxShipped">
</vn-date-picker>
</vn-horizontal>
<vn-horizontal>
<vn-autocomplete
url="Clients"
label="From client"
search-function="{or: [{id: $search}, {name: {like: '%'+$search+'%'}}]}"
show-field="name"
value-field="id"
ng-model="$ctrl.invoice.fromClientId">
<tpl-item>{{::id}} - {{::name}}</tpl-item>
</vn-autocomplete>
<vn-autocomplete
url="Clients"
label="To client"
search-function="{or: [{id: $search}, {name: {like: '%'+$search+'%'}}]}"
show-field="name"
value-field="id"
ng-model="$ctrl.invoice.toClientId">
<tpl-item>{{::id}} - {{::name}}</tpl-item>
</vn-autocomplete>
</vn-horizontal>
<vn-horizontal>
<vn-autocomplete
url="Companies"
label="Company"
show-field="code"
value-field="id"
ng-model="$ctrl.invoice.companyFk">
</vn-autocomplete>
</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,81 @@
import ngModule from '../../module';
import Dialog from 'core/components/dialog';
import './style.scss';
class Controller extends Dialog {
constructor($element, $, $transclude) {
super($element, $, $transclude);
this.isInvoicing = false;
this.invoice = {
maxShipped: new Date()
};
}
$onInit() {
this.getMinClientId();
this.getMaxClientId();
}
getMinClientId() {
this.getClientId('min')
.then(res => this.invoice.fromClientId = res.data.id);
}
getMaxClientId() {
this.getClientId('max')
.then(res => this.invoice.toClientId = res.data.id);
}
getClientId(func) {
const order = func == 'min' ? 'ASC' : 'DESC';
const params = {
filter: {
order: 'id ' + order,
limit: 1
}
};
return this.$http.get('Clients/findOne', {params});
}
get companyFk() {
return this.invoice.companyFk;
}
set companyFk(value) {
this.invoice.companyFk = value;
}
responseHandler(response) {
try {
if (response !== 'accept')
return super.responseHandler(response);
if (!this.invoice.invoiceDate || !this.invoice.maxShipped)
throw new Error('Invoice date and the max date should be filled');
if (!this.invoice.fromClientId || !this.invoice.toClientId)
throw new Error('Choose a valid clients range');
this.isInvoicing = true;
return this.$http.post(`InvoiceOuts/globalInvoicing`, this.invoice)
.then(() => super.responseHandler(response))
.then(() => this.vnApp.showSuccess(this.$t('Data saved!')))
.finally(() => this.isInvoicing = false);
} catch (e) {
this.vnApp.showError(this.$t(e.message));
this.isInvoicing = false;
return false;
}
}
}
Controller.$inject = ['$element', '$scope', '$transclude'];
ngModule.vnComponent('vnInvoiceOutGlobalInvoicing', {
slotTemplate: require('./index.html'),
controller: Controller,
bindings: {
companyFk: '<?'
}
});

View File

@ -0,0 +1,103 @@
import './index';
describe('InvoiceOut', () => {
describe('Component vnInvoiceOutGlobalInvoicing', () => {
let controller;
let $httpBackend;
let $httpParamSerializer;
beforeEach(ngModule('invoiceOut'));
beforeEach(inject(($componentController, $rootScope, _$httpBackend_, _$httpParamSerializer_) => {
$httpBackend = _$httpBackend_;
$httpParamSerializer = _$httpParamSerializer_;
let $scope = $rootScope.$new();
const $element = angular.element('<vn-invoice-out-global-invoicing></vn-invoice-out-global-invoicing>');
const $transclude = {
$$boundTransclude: {
$$slots: []
}
};
controller = $componentController('vnInvoiceOutGlobalInvoicing', {$element, $scope, $transclude});
}));
describe('getMinClientId()', () => {
it('should set the invoice fromClientId property', () => {
const filter = {
order: 'id ASC',
limit: 1
};
const serializedParams = $httpParamSerializer({filter});
$httpBackend.expectGET(`Clients/findOne?${serializedParams}`).respond(200, {id: 1101});
controller.getMinClientId();
$httpBackend.flush();
expect(controller.invoice.fromClientId).toEqual(1101);
});
});
describe('getMaxClientId()', () => {
it('should set the invoice toClientId property', () => {
const filter = {
order: 'id DESC',
limit: 1
};
const serializedParams = $httpParamSerializer({filter});
$httpBackend.expectGET(`Clients/findOne?${serializedParams}`).respond(200, {id: 1112});
controller.getMaxClientId();
$httpBackend.flush();
expect(controller.invoice.toClientId).toEqual(1112);
});
});
describe('responseHandler()', () => {
it('should throw an error when invoiceDate or maxShipped properties are not filled in', () => {
jest.spyOn(controller.vnApp, 'showError');
controller.invoice = {
fromClientId: 1101,
toClientId: 1101
};
controller.responseHandler('accept');
expect(controller.vnApp.showError).toHaveBeenCalledWith(`Invoice date and the max date should be filled`);
});
it('should throw an error when fromClientId or toClientId properties are not filled in', () => {
jest.spyOn(controller.vnApp, 'showError');
controller.invoice = {
invoiceDate: new Date(),
maxShipped: new Date()
};
controller.responseHandler('accept');
expect(controller.vnApp.showError).toHaveBeenCalledWith(`Choose a valid clients range`);
});
it('should make an http POST query and then call to the showSuccess() method', () => {
jest.spyOn(controller.vnApp, 'showSuccess');
controller.invoice = {
invoiceDate: new Date(),
maxShipped: new Date(),
fromClientId: 1101,
toClientId: 1101
};
$httpBackend.expect('POST', `InvoiceOuts/globalInvoicing`).respond({id: 1});
controller.responseHandler('accept');
$httpBackend.flush();
expect(controller.vnApp.showSuccess).toHaveBeenCalled();
});
});
});
});

View File

@ -0,0 +1,9 @@
Create global invoice: Crear factura global
Some fields are required: Algunos campos son obligatorios
Max date: Fecha límite
Invoicing in progress...: Facturación en progreso...
Invoice date: Fecha de factura
From client: Desde el cliente
To client: Hasta el cliente
Invoice date and the max date should be filled: La fecha de factura y la fecha límite deben rellenarse
Choose a valid clients range: Selecciona un rango válido de clientes

View File

@ -0,0 +1,17 @@
@import "variables";
.vn-invoice-out-global-invoicing {
tpl-body {
width: 500px;
.progress {
font-weight: bold;
text-align: center;
font-size: 1.5rem;
color: $color-primary;
vn-horizontal {
justify-content: center
}
}
}
}

View File

@ -74,6 +74,11 @@
ng-click="manualInvoicing.show()">
Manual invoicing
</vn-item>
<vn-item translate
name="globalInvoice"
ng-click="globalInvoicing.show()">
Global invoicing
</vn-item>
</vn-menu>
</vn-vertical>
</div>
@ -87,4 +92,8 @@
</vn-client-descriptor-popover>
<vn-invoice-out-manual
vn-id="manual-invoicing">
</vn-invoice-out-manual>
</vn-invoice-out-manual>
<vn-invoice-out-global-invoicing
vn-id="global-invoicing"
company-fk="$ctrl.vnConfig.companyFk">
</vn-invoice-out-global-invoicing>

View File

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

View File

@ -14,6 +14,14 @@
data="taxAreas"
order="code">
</vn-crud-model>
<div
class="progress vn-my-md"
ng-if="$ctrl.isInvoicing">
<vn-horizontal>
<vn-icon vn-none icon="warning"></vn-icon>
<span vn-none translate>Invoicing in progress...</span>
</vn-horizontal>
</div>
<vn-horizontal class="manifold-panel">
<vn-autocomplete
url="Tickets"

View File

@ -6,6 +6,7 @@ class Controller extends Dialog {
constructor($element, $, $transclude) {
super($element, $, $transclude);
this.isInvoicing = false;
this.invoice = {
maxShipped: new Date()
};
@ -22,14 +23,17 @@ class Controller extends Dialog {
if (!this.invoice.serial || !this.invoice.taxArea)
throw new Error('Some fields are required');
this.isInvoicing = true;
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!')));
.then(() => this.vnApp.showSuccess(this.$t('Data saved!')))
.finally(() => this.isInvoicing = false);
} catch (e) {
this.vnApp.showError(this.$t(e.message));
this.isInvoicing = false;
return false;
}
}

View File

@ -2,4 +2,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
Serial: Serie
Invoicing in progress...: Facturación en progreso...

View File

@ -1,5 +1,17 @@
@import "variables";
.vn-invoice-out-manual {
tpl-body {
width: 500px
width: 500px;
.progress {
font-weight: bold;
text-align: center;
font-size: 1.5rem;
color: $color-primary;
vn-horizontal {
justify-content: center
}
}
}
}

View File

@ -100,13 +100,14 @@ module.exports = function(Self) {
}, myOptions);
}
if (serial != 'R' && invoiceId) {
if (serial != 'R' && invoiceId)
await Self.rawSql('CALL invoiceOutBooking(?)', [invoiceId], myOptions);
await models.InvoiceOut.createPdf(ctx, invoiceId, myOptions);
}
if (tx) await tx.commit();
if (serial != 'R' && invoiceId)
await models.InvoiceOut.createPdf(ctx, invoiceId);
return {invoiceFk: invoiceId, serial: serial};
} catch (e) {
if (tx) await tx.rollback();

45574
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff