Merge branch 'dev' into 2940-invoiceInTax-section

This commit is contained in:
Javi Gallego 2021-08-12 09:39:35 +02:00
commit 87fe3c529a
52 changed files with 2279 additions and 1212 deletions

View File

@ -2,7 +2,13 @@ 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, principalId, property, accessType)
VALUES ('InvoiceInTax','administrative', '*', '*');
INSERT INTO salix.ACL (model, principalId, property, accessType)
VALUES ('InvoiceInTax','administrative', '*', '*'),
VALUES ('InvoiceInLog','administrative', '*', 'READ');
INSERT INTO `salix`.`ACL` (model, property, accessType, permission, principalType, principalId)
VALUES
('InvoiceOut', 'createManualInvoice', 'WRITE', 'ALLOW', 'ROLE', 'invoicing'),
('InvoiceOut', 'globalInvoicing', 'WRITE', 'ALLOW', 'ROLE', 'invoicing');

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

@ -131,7 +131,8 @@ INSERT INTO `vn`.`warehouse`(`id`, `name`, `code`, `isComparative`, `isInventory
(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);
(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

@ -915,6 +915,19 @@ 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"]',
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"]',
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

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

@ -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,19 +1,25 @@
<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-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
@ -31,6 +37,26 @@
</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();
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
Rotate left: Girar a la izquierda
Rotate right: Girar a la derecha
Panoramic: Panorámico

View File

@ -1,24 +1,9 @@
@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 {
@ -28,10 +13,13 @@
}
vn-input-file {
max-width: 256px;
div.control {
overflow: hidden
}
}
.form {
align-items: initial;
}
}

View File

@ -105,9 +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"
"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

@ -195,5 +195,15 @@
"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",
"INACTIVE_PROVIDER": "INACTIVE_PROVIDER"
"INACTIVE_PROVIDER": "INACTIVE_PROVIDER",
"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",
"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

@ -0,0 +1,183 @@
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 (tx) await tx.commit();
if (newInvoice.id)
await Self.createPdf(ctx, newInvoice.id);
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,24 +58,33 @@ 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});
if (tx) await tx.commit();
const writeStream = fs.createWriteStream(fileSrc);
writeStream.on('open', () => {
response.pipe(writeStream);
});
writeStream.on('finish', async function() {
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

@ -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}`,
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`
};
} else {
file = {
path: `${process.cwd()}/README.md`,
contentType: 'text/plain',
name: `README.md`
};
}
await fs.access(file.path);
let stream = fs.createReadStream(file.path);

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

@ -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(new Promise(resolve => resolve(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(new Promise(resolve => resolve(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(new Promise(resolve => resolve(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(new Promise(resolve => resolve(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(new Promise(resolve => resolve(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

@ -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,9 +19,27 @@ 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 result = await models.InvoiceOut.createPdf(ctx, invoiceId);
const tx = await models.InvoiceOut.beginTransaction({});
const options = {transaction: tx};
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

@ -1,10 +1,17 @@
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('text/plain');
expect(result[2]).toEqual('filename="README.md"');
expect(result[1]).toEqual('application/pdf');
expect(result[2]).toEqual('filename="1.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

@ -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,6 @@ module.exports = Self => {
require('../methods/invoiceOut/delete')(Self);
require('../methods/invoiceOut/book')(Self);
require('../methods/invoiceOut/createPdf')(Self);
require('../methods/invoiceOut/createManualInvoice')(Self);
require('../methods/invoiceOut/globalInvoicing')(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,5 @@ import './summary';
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

@ -57,6 +57,31 @@
</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-item translate
name="globalInvoice"
ng-click="globalInvoicing.show()">
Global invoicing
</vn-item>
</vn-menu>
</vn-vertical>
</div>
<vn-popup vn-id="summary">
<vn-invoice-out-summary
invoice-out="$ctrl.selectedInvoiceOut">
@ -65,3 +90,10 @@
<vn-client-descriptor-popover
vn-id="clientDescriptor">
</vn-client-descriptor-popover>
<vn-invoice-out-manual
vn-id="manual-invoicing">
</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,3 +4,5 @@ Due date: Fecha vencimiento
Has PDF: PDF disponible
Minimum: Minimo
Maximum: Máximo
Global invoicing: Facturación global
Manual invoicing: Facturación manual

View File

@ -0,0 +1,83 @@
<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>
<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"
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,51 @@
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()
};
}
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');
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!')))
.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('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,6 @@
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
Invoicing in progress...: Facturación en progreso...

View File

@ -0,0 +1,17 @@
@import "variables";
.vn-invoice-out-manual {
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

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

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

1267
package-lock.json generated

File diff suppressed because it is too large Load Diff