Merge branch '5347-clientCreate-→-client_create' of https://gitea.verdnatura.es/verdnatura/salix into 5347-clientCreate-→-client_create
gitea/salix/pipeline/head There was a failure building this commit Details

This commit is contained in:
Pablo Natek 2023-06-29 08:14:10 +02:00
commit 89b63f47fc
44 changed files with 1134 additions and 657 deletions

View File

@ -17,7 +17,7 @@ rules:
camelcase: 0 camelcase: 0
default-case: 0 default-case: 0
no-eq-null: 0 no-eq-null: 0
no-console: ["error"] no-console: ["warn"]
no-warning-comments: 0 no-warning-comments: 0
no-empty: [error, allowEmptyCatch: true] no-empty: [error, allowEmptyCatch: true]
complexity: 0 complexity: 0

View File

@ -18,11 +18,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- (Entradas -> Correo) Al cambiar el tipo de cambio enviará un correo a las personas designadas - (Entradas -> Correo) Al cambiar el tipo de cambio enviará un correo a las personas designadas
- (General -> Históricos) Botón para ver el estado del registro en cada punto
- (General -> Históricos) Al filtar por registro se muestra todo el histórial desde que fue creado
### Changed ### Changed
- (General -> Históricos) Los registros se muestran agrupados por usuario y entidad
- (Facturas -> Facturación global) Optimizada, generación de PDFs y notificaciones en paralelo
### Fixed ### Fixed
- - (General -> Históricos) Duplicidades eliminadas
- (Facturas -> Facturación global) Solucionados fallos que paran el proceso
## [2324.01] - 2023-06-15 ## [2324.01] - 2023-06-15

View File

@ -0,0 +1,22 @@
CREATE TABLE `vn`.`travelConfig` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`warehouseInFk` smallint(6) unsigned NOT NULL DEFAULT 8 COMMENT 'Warehouse de origen',
`warehouseOutFk` smallint(6) unsigned NOT NULL DEFAULT 60 COMMENT 'Warehouse destino',
`agencyFk` int(11) NOT NULL DEFAULT 1378 COMMENT 'Agencia por defecto',
`companyFk` int(10) unsigned NOT NULL DEFAULT 442 COMMENT 'Compañía por defecto',
PRIMARY KEY (`id`),
KEY `travelConfig_FK` (`warehouseInFk`),
KEY `travelConfig_FK_1` (`warehouseOutFk`),
KEY `travelConfig_FK_2` (`agencyFk`),
KEY `travelConfig_FK_3` (`companyFk`),
CONSTRAINT `travelConfig_FK` FOREIGN KEY (`warehouseInFk`) REFERENCES `warehouse` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `travelConfig_FK_1` FOREIGN KEY (`warehouseOutFk`) REFERENCES `warehouse` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `travelConfig_FK_2` FOREIGN KEY (`agencyFk`) REFERENCES `agencyMode` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `travelConfig_FK_3` FOREIGN KEY (`companyFk`) REFERENCES `company` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
INSERT INTO `salix`.`ACL` (`model`, `property`, `accessType`, `permission`, `principalType`, `principalId`)
VALUES
('Entry', 'addFromPackaging', 'WRITE', 'ALLOW', 'ROLE', 'production'),
('Entry', 'addFromBuy', 'WRITE', 'ALLOW', 'ROLE', 'production'),
('Supplier', 'getItemsPackaging', 'READ', 'ALLOW', 'ROLE', 'production');

View File

@ -0,0 +1,13 @@
INSERT INTO salix.ACL (model,property,accessType,permission,principalType,principalId)
VALUES
('InvoiceOut','makePdfAndNotify','WRITE','ALLOW','ROLE','invoicing'),
('InvoiceOutConfig','*','READ','ALLOW','ROLE','invoicing');
CREATE OR REPLACE TABLE vn.invoiceOutConfig (
id INT UNSIGNED auto_increment NOT NULL,
parallelism int UNSIGNED DEFAULT 1 NOT NULL,
PRIMARY KEY (id)
)
ENGINE=InnoDB
DEFAULT CHARSET=utf8mb3
COLLATE=utf8mb3_unicode_ci;

View File

@ -603,6 +603,9 @@ UPDATE `vn`.`invoiceOut` SET ref = 'T3333333' WHERE id = 3;
UPDATE `vn`.`invoiceOut` SET ref = 'T4444444' WHERE id = 4; UPDATE `vn`.`invoiceOut` SET ref = 'T4444444' WHERE id = 4;
UPDATE `vn`.`invoiceOut` SET ref = 'A1111111' WHERE id = 5; UPDATE `vn`.`invoiceOut` SET ref = 'A1111111' WHERE id = 5;
INSERT INTO vn.invoiceOutConfig
SET parallelism = 8;
INSERT INTO `vn`.`invoiceOutTax` (`invoiceOutFk`, `taxableBase`, `vat`, `pgcFk`) INSERT INTO `vn`.`invoiceOutTax` (`invoiceOutFk`, `taxableBase`, `vat`, `pgcFk`)
VALUES VALUES
(1, 895.76, 89.58, 4722000010), (1, 895.76, 89.58, 4722000010),
@ -2839,7 +2842,8 @@ INSERT INTO `vn`.`workerConfig` (`id`, `businessUpdated`, `roleFk`, `payMethodFk
INSERT INTO `vn`.`ticketRefund`(`refundTicketFk`, `originalTicketFk`) INSERT INTO `vn`.`ticketRefund`(`refundTicketFk`, `originalTicketFk`)
VALUES VALUES
(1, 12); (1, 12),
(8, 10);
INSERT INTO `vn`.`deviceProductionModels` (`code`) INSERT INTO `vn`.`deviceProductionModels` (`code`)
VALUES VALUES

View File

@ -479,9 +479,6 @@ export default {
fourthBalance: 'vn-item-diary vn-tbody > vn-tr:nth-child(4) > vn-td.balance > span', fourthBalance: 'vn-item-diary vn-tbody > vn-tr:nth-child(4) > vn-td.balance > span',
firstBalance: 'vn-item-diary vn-tbody > vn-tr:nth-child(1) > vn-td.balance' firstBalance: 'vn-item-diary vn-tbody > vn-tr:nth-child(1) > vn-td.balance'
}, },
itemLog: {
anyLineCreated: 'vn-item-log > vn-log vn-tbody > vn-tr',
},
ticketSummary: { ticketSummary: {
header: 'vn-ticket-summary > vn-card > h5', header: 'vn-ticket-summary > vn-card > h5',
state: 'vn-ticket-summary vn-label-value[label="State"] > section > span', state: 'vn-ticket-summary vn-label-value[label="State"] > section > span',
@ -667,15 +664,6 @@ export default {
thirdRemoveRequestButton: 'vn-ticket-request-index vn-tr:nth-child(3) vn-icon[icon="delete"]', thirdRemoveRequestButton: 'vn-ticket-request-index vn-tr:nth-child(3) vn-icon[icon="delete"]',
thirdRequestQuantity: 'vn-ticket-request-index vn-table vn-tr:nth-child(3) > vn-td:nth-child(6) vn-input-number', thirdRequestQuantity: 'vn-ticket-request-index vn-table vn-tr:nth-child(3) > vn-td:nth-child(6) vn-input-number',
saveButton: 'vn-ticket-request-create button[type=submit]', saveButton: 'vn-ticket-request-create button[type=submit]',
},
ticketLog: {
firstTD: 'vn-ticket-log vn-table vn-td:nth-child(1)',
logButton: 'vn-left-menu a[ui-sref="ticket.card.log"]',
user: 'vn-ticket-log vn-tbody vn-tr vn-td:nth-child(2)',
action: 'vn-ticket-log vn-tbody vn-tr vn-td:nth-child(4)',
changes: 'vn-ticket-log vn-data-viewer vn-tbody vn-tr table tr:nth-child(2) td.after',
id: 'vn-ticket-log vn-tr:nth-child(1) table tr:nth-child(1) td.before'
}, },
ticketService: { ticketService: {
addServiceButton: 'vn-ticket-service vn-icon-button[vn-tooltip="Add service"] > button', addServiceButton: 'vn-ticket-service vn-icon-button[vn-tooltip="Add service"] > button',

View File

@ -5,8 +5,8 @@ const $ = {
userName: 'vn-client-web-access vn-textfield[ng-model="$ctrl.account.name"]', userName: 'vn-client-web-access vn-textfield[ng-model="$ctrl.account.name"]',
email: 'vn-client-web-access vn-textfield[ng-model="$ctrl.account.email"]', email: 'vn-client-web-access vn-textfield[ng-model="$ctrl.account.email"]',
saveButton: 'vn-client-web-access button[type=submit]', saveButton: 'vn-client-web-access button[type=submit]',
nameValue: 'vn-client-log .change:nth-child(1) .basic-json:nth-child(2) vn-json-value', nameValue: 'vn-client-log .changes-log:nth-child(2) .basic-json:nth-child(2) vn-json-value',
activeValue: 'vn-client-log .change:nth-child(2) .basic-json:nth-child(1) vn-json-value' activeValue: 'vn-client-log .changes-log:nth-child(3) .basic-json:nth-child(1) vn-json-value'
}; };
describe('Client web access path', () => { describe('Client web access path', () => {

View File

@ -4,8 +4,8 @@ vn-avatar {
display: block; display: block;
border-radius: 50%; border-radius: 50%;
overflow: hidden; overflow: hidden;
height: 36px; height: 38px;
width: 36px; width: 38px;
font-size: 22px; font-size: 22px;
background-color: $color-main; background-color: $color-main;
position: relative; position: relative;

View File

@ -2,8 +2,6 @@
vn-id="model" vn-id="model"
url="{{$ctrl.url}}" url="{{$ctrl.url}}"
filter="$ctrl.filter" filter="$ctrl.filter"
link="{originFk: $ctrl.originId}"
where="{changedModel: $ctrl.changedModel, changedModelId: $ctrl.changedModelId}"
data="$ctrl.logs" data="$ctrl.logs"
order="creationDate DESC, id DESC" order="creationDate DESC, id DESC"
limit="20"> limit="20">
@ -17,90 +15,108 @@
<vn-data-viewer <vn-data-viewer
model="model" model="model"
class="vn-w-sm vn-px-sm vn-pb-xl"> class="vn-w-sm vn-px-sm vn-pb-xl">
<div class="change vn-mb-sm" ng-repeat="log in $ctrl.logs"> <div class="origin-log" ng-repeat="originLog in $ctrl.logTree">
<div class="left"> <div class="origin-info vn-mb-md" ng-if="::$ctrl.logTree.length > 1">
<vn-avatar class="vn-mt-xs" <h6 class="origin-id">
ng-class="::{system: !log.user}" {{::$ctrl.modelI18n}} #{{::originLog.originFk}}
val="{{::log.user ? log.user.nickname : $ctrl.$t('System')}}" </h6>
ng-click="$ctrl.showWorkerDescriptor($event, log)">
<img
ng-if="::log.user.image"
ng-src="/api/Images/user/160x160/{{::log.userFk}}/download?access_token={{::$ctrl.vnToken.token}}">
</img>
</vn-avatar>
<div class="arrow bg-panel"></div>
<div class="line"></div> <div class="line"></div>
</div> </div>
<vn-card class="detail"> <div class="user-log vn-mb-sm" ng-repeat="userLog in ::originLog.logs">
<div class="header vn-pa-sm"> <div class="timeline">
<div class="action-model"> <vn-avatar
<span class="model-name" ng-class="::{system: !userLog.user}"
ng-if="::$ctrl.showModelName && log.changedModel" val="{{::userLog.user ? userLog.user.nickname : $ctrl.$t('System')}}"
ng-style="::{backgroundColor: $ctrl.hashToColor(log.changedModel)}" ng-click="$ctrl.showWorkerDescriptor($event, userLog)">
title="{{::log.changedModel}}"> <img
{{::log.changedModelI18n}} ng-if="::userLog.user.image"
</span> ng-src="/api/Images/user/160x160/{{::userLog.userFk}}/download?access_token={{::$ctrl.vnToken.token}}">
</div> </img>
<div </vn-avatar>
class="action-date text-secondary text-caption vn-ml-sm" <div class="arrow bg-panel" ng-if="::$ctrl.byRecord"></div>
title="{{::log.creationDate | date:'dd/MM/yyyy HH:mm:ss'}}"> <div class="line"></div>
{{::$ctrl.relativeDate(log.creationDate)}}
<vn-icon
class="action vn-ml-xs"
ng-class="::$ctrl.actionsClass[log.action]"
icon="{{::$ctrl.actionsIcon[log.action]}}"
translate-attr="::{title: $ctrl.actionsText[log.action]}">
</vn-icon>
</div>
</div> </div>
<div class="model vn-pb-sm vn-px-sm" <div class="user-changes">
ng-if="::$ctrl.showModelName"> <div class="model-log" ng-repeat="modelLog in ::userLog.logs">
<span class="model-id" ng-if="::log.changedModelId">#{{::log.changedModelId}}</span> <div class="model-info vn-my-sm" ng-if="::!$ctrl.byRecord">
<vn-icon <vn-icon
icon="filter_alt" icon="filter_alt"
translate-attr="{title: 'Show all record changes'}" translate-attr="{title: 'Show all record changes'}"
ng-click="$ctrl.filterByEntity(log)"> ng-click="$ctrl.filterByRecord(modelLog)">
</vn-icon> </vn-icon>
<span class="model-value" title="{{::log.changedModelValue}}">{{::log.changedModelValue}}</span> <span class="model-name"
</div> ng-if="::$ctrl.showModelName && modelLog.model"
<div class="changes vn-pa-sm" ng-style="::{backgroundColor: $ctrl.hashToColor(modelLog.model)}"
ng-class="{expanded: log.expand}" title="{{::modelLog.model}}">
ng-if="::log.props.length || log.description"> {{::modelLog.modelI18n}}
<vn-icon
icon="expand_more"
translate-attr="{title: 'Details'}"
ng-click="log.expand = !log.expand">
</vn-icon>
<span ng-if="::log.props.length"
class="attributes">
<span ng-if="!log.expand" ng-repeat="prop in ::log.props"
class="basic-json">
<span class="json-field" title="{{::prop.name}}">
{{::prop.nameI18n}}:
</span> </span>
<vn-json-value value="::prop.val.val"></vn-json-value><span ng-if="::!$last">,</span> <span class="model-id" ng-if="::modelLog.id">#{{::modelLog.id}}</span>
</span> <span class="model-value" title="{{::modelLog.showValue}}">{{::modelLog.showValue}}</span>
<div ng-if="log.expand" class="expanded-json">
<div ng-repeat="prop in ::log.props">
<span class="json-field" title="{{::prop.name}}">
{{::prop.nameI18n}}:
</span>
<vn-log-value val="::prop.val"></vn-log-value>
<span ng-if="::log.action == 'update'">
<vn-log-value val="::prop.old"></vn-log-value>
</span>
</div>
</div> </div>
</span> <vn-card class="changes-log vn-mb-xs" ng-repeat="log in ::modelLog.logs">
<span ng-if="::!log.props.length" class="description"> <div class="change-info vn-pa-sm">
{{::log.description}} <div
</span> class="date text-secondary text-caption vn-mr-sm"
</vn-card> title="{{::log.creationDate | date:'dd/MM/yyyy HH:mm:ss'}}">
{{::$ctrl.relativeDate(log.creationDate)}}
</div>
<div>
<vn-icon
class="pit vn-ml-xs"
icon="preview"
translate-attr="::{title: 'View record at this point in time'}"
ng-show="::log.action != 'insert'"
ng-click="$ctrl.viewPitInstance($event, log.id, modelLog)">
</vn-icon>
<vn-icon
class="action vn-ml-xs"
ng-class="::$ctrl.actionsClass[log.action]"
icon="{{::$ctrl.actionsIcon[log.action]}}"
translate-attr="::{title: $ctrl.actionsText[log.action]}">
</vn-icon>
</div>
</div>
<div class="change-detail vn-pa-sm"
ng-class="{expanded: log.expand}"
ng-if="::log.props.length || log.description">
<vn-icon
icon="expand_more"
translate-attr="{title: 'Details'}"
ng-click="log.expand = !log.expand">
</vn-icon>
<span ng-if="::log.props.length"
class="attributes">
<span ng-if="!log.expand" ng-repeat="prop in ::log.props"
class="basic-json">
<span class="json-field" title="{{::prop.name}}">
{{::prop.nameI18n}}:
</span>
<vn-json-value value="::prop.val.val"></vn-json-value><span ng-if="::!$last">,</span>
</span>
<div ng-if="log.expand" class="expanded-json">
<div ng-repeat="prop in ::log.props">
<span class="json-field" title="{{::prop.name}}">
{{::prop.nameI18n}}:
</span>
<vn-log-value val="::prop.val"></vn-log-value>
<span ng-if="::log.action == 'update'">
<vn-log-value val="::prop.old"></vn-log-value>
</span>
</div>
</div>
</span>
<span ng-if="::!log.props.length" class="description">
{{::log.description}}
</span>
</vn-card>
</div>
</div>
</div>
</div> </div>
</div> </div>
</vn-data-viewer> </vn-data-viewer>
<vn-float-button <vn-float-button
ng-if="model.userFilter" ng-if="$ctrl.hasFilter"
icon="filter_alt_off" icon="filter_alt_off"
translate-attr="{title: 'Quit filter'}" translate-attr="{title: 'Quit filter'}"
ng-click="$ctrl.resetFilter()" ng-click="$ctrl.resetFilter()"
@ -212,5 +228,33 @@
</vn-date-picker> </vn-date-picker>
</form> </form>
</vn-side-menu> </vn-side-menu>
<vn-worker-descriptor-popover vn-id="workerDescriptor"> <vn-popover vn-id="instance-popover">
<tpl-body class="vn-log-instance">
<vn-spinner
ng-if="$ctrl.instance.canceler"
class="loading vn-pa-sm"
enable="true">
</vn-spinner>
<div
ng-if="!$ctrl.instance.canceler" class="instance">
<h6 class="header vn-pa-sm">
{{$ctrl.instance.modelLog.modelI18n}} #{{$ctrl.instance.modelLog.id}}
</h6>
<div class="change-detail vn-pa-sm">
<div ng-if="$ctrl.instance.props"
ng-repeat="prop in $ctrl.instance.props">
<span class="json-field" title="{{::prop.name}}">
{{::prop.nameI18n}}:
</span>
<vn-log-value val="::prop.val"></vn-log-value>
</div>
<div ng-if="!$ctrl.instance.props" translate>
No data
</div>
</div>
</div>
</tpl-body>
</vn-popover>
<vn-worker-descriptor-popover
vn-id="worker-descriptor">
</vn-worker-descriptor-popover> </vn-worker-descriptor-popover>

View File

@ -3,7 +3,10 @@ import Section from '../section';
import {hashToColor} from 'core/lib/string'; import {hashToColor} from 'core/lib/string';
import './style.scss'; import './style.scss';
const validDate = /^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])(.[0-9]+)?(Z)?$/; const validDate = new RegExp(
/^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])/.source
+ /T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])(.[0-9]+)?(Z)?$/.source
);
export default class Controller extends Section { export default class Controller extends Section {
constructor($element, $) { constructor($element, $) {
@ -28,6 +31,20 @@ export default class Controller extends Section {
select: 'visibility' select: 'visibility'
}; };
this.filter = { this.filter = {
fields: [
'id',
'originFk',
'userFk',
'action',
'changedModel',
'oldInstance',
'newInstance',
'creationDate',
'changedModel',
'changedModelId',
'changedModelValue',
'description'
],
include: [{ include: [{
relation: 'user', relation: 'user',
scope: { scope: {
@ -48,6 +65,11 @@ export default class Controller extends Section {
this.today.setHours(0, 0, 0, 0); this.today.setHours(0, 0, 0, 0);
} }
$onInit() {
const match = this.url?.match(/(.*)Logs$/);
this.modelI18n = match && this.translateModel(match[1]);
}
$postLink() { $postLink() {
this.resetFilter(); this.resetFilter();
this.$.$watch( this.$.$watch(
@ -63,47 +85,75 @@ export default class Controller extends Section {
set logs(value) { set logs(value) {
this._logs = value; this._logs = value;
this.logTree = [];
if (!value) return; if (!value) return;
const empty = {}; const empty = {};
const validations = window.validations; const validations = window.validations;
const castJsonValue = this.castJsonValue;
for (const log of value) { let originLog;
let userLog;
let modelLog;
let nLogs;
for (let i = 0; i < value.length; i++) {
const log = value[i];
const prevLog = i > 0 ? value[i - 1] : null;
const locale = validations[log.changedModel]?.locale || empty;
// Origin
const originChanged = !prevLog
|| log.originFk != prevLog.originFk;
if (originChanged) {
this.logTree.push(originLog = {
originFk: log.originFk,
logs: []
});
}
// User
const userChanged = originChanged
|| log.userFk != prevLog.userFk
|| nLogs >= 5;
if (userChanged) {
originLog.logs.push(userLog = {
user: log.user,
userFk: log.userFk,
logs: []
});
nLogs = 0;
}
nLogs++;
// Model
const modelChanged = userChanged
|| log.changedModel != prevLog.changedModel
|| log.changedModelId != prevLog.changedModelId;
if (modelChanged) {
userLog.logs.push(modelLog = {
model: log.changedModel,
modelI18n: firstUpper(locale.name) || log.changedModel,
id: log.changedModelId,
showValue: log.changedModelValue,
logs: []
});
}
modelLog.logs.push(log);
// Changes
const notDelete = log.action != 'delete'; const notDelete = log.action != 'delete';
const olds = (notDelete ? log.oldInstance : null) || empty; const olds = (notDelete ? log.oldInstance : null) || empty;
const vals = (notDelete ? log.newInstance : log.oldInstance) || empty; const vals = (notDelete ? log.newInstance : log.oldInstance) || empty;
const locale = validations[log.changedModel]?.locale || empty;
log.changedModelI18n = firstUpper(locale.name) || log.changedModel;
let props = Object.keys(olds).concat(Object.keys(vals)); let propNames = Object.keys(olds).concat(Object.keys(vals));
props = [...new Set(props)]; propNames = [...new Set(propNames)];
log.props = []; log.props = this.parseProps(propNames, locale, vals, olds);
for (const prop of props) {
if (prop.endsWith('$')) continue;
log.props.push({
name: prop,
nameI18n: firstUpper(locale.columns?.[prop]) || prop,
old: getVal(olds, prop),
val: getVal(vals, prop)
});
}
log.props.sort(
(a, b) => a.nameI18n.localeCompare(b.nameI18n));
}
function getVal(vals, prop) {
let val, id;
const showProp = `${prop}$`;
if (vals[showProp] != null) {
val = vals[showProp];
id = vals[prop];
} else
val = vals[prop];
return {val: castJsonValue(val), id};
} }
} }
@ -114,17 +164,76 @@ export default class Controller extends Section {
set models(value) { set models(value) {
this._models = value; this._models = value;
if (!value) return; if (!value) return;
for (const model of value) { for (const model of value)
const name = model.changedModel; model.changedModelI18n = this.translateModel(model.changedModel);
model.changedModelI18n =
firstUpper(window.validations[name]?.locale?.name) || name;
}
} }
get showModelName() { get showModelName() {
return !(this.changedModel && this.changedModelId); return !(this.changedModel && this.changedModelId);
} }
parseProps(propNames, locale, vals, olds) {
const castJsonValue = this.castJsonValue;
const props = [];
for (const prop of propNames) {
if (prop.endsWith('$')) continue;
props.push({
name: prop,
nameI18n: firstUpper(locale.columns?.[prop]) || prop,
val: getVal(vals, prop),
old: olds && getVal(olds, prop)
});
}
props.sort(
(a, b) => a.nameI18n.localeCompare(b.nameI18n));
function getVal(vals, prop) {
let val; let id;
const showProp = `${prop}$`;
if (vals[showProp] != null) {
val = vals[showProp];
id = vals[prop];
} else
val = vals[prop];
return {val: castJsonValue(val), id};
}
return props;
}
viewPitInstance(event, id, modelLog) {
if (this.instance?.canceler)
this.instance.canceler.resolve();
const canceler = this.$q.defer();
this.instance = {
modelLog,
canceler
};
const options = {timeout: canceler.promise};
this.$http.get(`${this.url}/${id}/pitInstance`, options)
.then(res => {
const instance = res.data;
const propNames = Object.keys(instance);
const locale = window.validations[modelLog.model]?.locale || {};
this.instance.props = this.parseProps(propNames, locale, instance);
})
.finally(() => {
this.instance.canceler = null;
this.$.$applyAsync(() => this.$.instancePopover.relocate());
});
this.$.instancePopover.show(event);
}
translateModel(name) {
return firstUpper(window.validations[name]?.locale?.name) || name;
}
castJsonValue(value) { castJsonValue(value) {
return typeof value === 'string' && validDate.test(value) return typeof value === 'string' && validDate.test(value)
? new Date(value) ? new Date(value)
@ -160,12 +269,11 @@ export default class Controller extends Section {
applyFilter() { applyFilter() {
const filter = this.$.filter; const filter = this.$.filter;
function getParam(prop, value) { const getParam = (prop, value) => {
if (value == null || value == '') return null; if (value == null || value == '') return null;
switch (prop) { switch (prop) {
case 'search': case 'search':
const or = []; if (/^\s*[0-9]+\s*$/.test(value) || this.byRecord)
if (/^\s*[0-9]+\s*$/.test(value))
return {changedModelId: value.trim()}; return {changedModelId: value.trim()};
else else
return {changedModelValue: {like: `%${value}%`}}; return {changedModelValue: {like: `%${value}%`}};
@ -177,72 +285,86 @@ export default class Controller extends Section {
]}; ]};
case 'who': case 'who':
switch (value) { switch (value) {
case 'all':
return null;
case 'user': case 'user':
return {userFk: {neq: null}}; return {userFk: {neq: null}};
case 'system': case 'system':
return {userFk: null}; return {userFk: null};
case 'all':
default:
return null;
} }
case 'actions': case 'actions': {
const inq = []; const inq = [];
for (const action in value) { for (const action in value) {
if (value[action]) if (value[action])
inq.push(action); inq.push(action);
} }
return inq.length ? {action: {inq}} : null; return inq.length ? {action: {inq}} : null;
}
case 'from': case 'from':
if (filter.to) { if (filter.to)
return {creationDate: {gte: value}}; return {creationDate: {gte: value}};
} else { else {
const to = new Date(value); const to = new Date(value);
to.setHours(23, 59, 59, 999); to.setHours(23, 59, 59, 999);
return {creationDate: {between: [value, to]}}; return {creationDate: {between: [value, to]}};
} }
case 'to': case 'to': {
const to = new Date(value); const to = new Date(value);
to.setHours(23, 59, 59, 999); to.setHours(23, 59, 59, 999);
return {creationDate: {lte: to}}; return {creationDate: {lte: to}};
}
case 'userFk': case 'userFk':
return filter.who != 'system' return filter.who != 'system'
? {[prop]: value} : null; ? {[prop]: value} : null;
default: default:
return {[prop]: value}; return {[prop]: value};
} }
} };
this.hasFilter = false;
const and = []; const and = [];
if (!filter.search || !filter.changedModel)
this.byRecord = false;
if (!this.byRecord)
and.push({originFk: this.originId});
for (const prop in filter) { for (const prop in filter) {
const param = getParam(prop, filter[prop]); const param = getParam(prop, filter[prop]);
if (param) and.push(param); if (param) {
and.push(param);
this.hasFilter = true;
}
} }
const lbFilter = and.length ? {where: {and}} : null; const lbFilter = and.length ? {where: {and}} : null;
return this.$.model.applyFilter(lbFilter); return this.$.model.applyFilter(lbFilter);
} }
filterByEntity(log) { filterByRecord(modelLog) {
this.byRecord = true;
this.$.filter = { this.$.filter = {
who: 'all', who: 'all',
search: log.changedModelId, search: modelLog.id,
changedModel: log.changedModel changedModel: modelLog.model
}; };
} }
searchUser(search) { searchUser(search) {
if (/^[0-9]+$/.test(search)) { if (/^[0-9]+$/.test(search))
return {id: search}; return {id: search};
} else { else {
return {or: [ return {or: [
{name: search}, {name: search},
{nickname: {like: `%${search}%`}} {nickname: {like: `%${search}%`}}
]} ]};
} }
} }
showWorkerDescriptor(event, log) { showWorkerDescriptor(event, userLog) {
if (log.user?.worker) if (userLog.user?.worker)
this.$.workerDescriptor.show(event.target, log.userFk); this.$.workerDescriptor.show(event.target, userLog.userFk);
} }
} }

View File

@ -24,4 +24,5 @@ Changes: Cambios
today: hoy today: hoy
yesterday: ayer yesterday: ayer
Show all record changes: Mostrar todos los cambios realizados en el registro Show all record changes: Mostrar todos los cambios realizados en el registro
View record at this point in time: Ver el registro en este punto
Quit filter: Quitar filtro Quit filter: Quitar filtro

View File

@ -1,13 +1,49 @@
@import "util"; @import "util";
vn-log { vn-log {
.change { .origin-log {
&:first-child > .origin-info {
margin-top: 0;
}
& > .origin-info {
display: flex;
align-items: center;
margin-top: 28px;
gap: 6px;
& > .origin-id {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: $color-font-secondary;
margin: 0;
}
& > .line {
flex-grow: 1;
background-color: $color-font-secondary;
height: 2px;
}
}
}
.user-log {
display: flex; display: flex;
& > .left { & > .timeline {
position: relative; position: relative;
padding-right: 10px; padding-right: 10px;
width: 38px;
min-width: 38px;
flex-grow: auto;
& > .arrow {
height: 8px;
width: 8px;
position: absolute;
transform: rotateY(0deg) rotate(45deg);
top: 15px;
right: -4px;
z-index: 1;
}
& > vn-avatar { & > vn-avatar {
cursor: pointer; cursor: pointer;
@ -15,153 +51,187 @@ vn-log {
background-color: $color-main !important; background-color: $color-main !important;
} }
} }
& > .arrow {
height: 8px;
width: 8px;
position: absolute;
transform: rotateY(0deg) rotate(45deg);
top: 18px;
right: -4px;
z-index: 1;
}
& > .line { & > .line {
position: absolute; position: absolute;
background-color: $color-main; background-color: $color-main;
width: 2px; width: 2px;
left: 17px; left: 18px;
z-index: -1; z-index: -1;
top: 44px; top: 44px;
bottom: -8px; bottom: -2px;
} }
} }
&:last-child > .left > .line { &:last-child > .timeline > .line {
display: none; display: none;
} }
.detail { & > .user-changes {
position: relative;
flex-grow: 1; flex-grow: 1;
width: 100%;
border-radius: 2px;
overflow: hidden; overflow: hidden;
}
}
.model-log {
& > .model-info {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
min-height: 22px;
& > .header { & > .model-name {
display: flex; display: inline-block;
justify-content: space-between; padding: 2px 5px;
align-items: center; color: $color-font-dark;
overflow: hidden; border-radius: 8px;
vertical-align: middle;
}
& > .model-value {
font-style: italic;
}
& > .model-id {
color: $color-font-secondary;
font-size: .9rem;
}
& > vn-icon[icon="filter_alt"] {
@extend %clickable-light;
vertical-align: middle;
font-size: 18px;
color: $color-font-secondary;
float: right;
display: none;
& > .action-model { @include mobile {
display: inline-flex; display: initial;
overflow: hidden;
& > .model-name {
display: inline-block;
padding: 2px 5px;
color: $color-font-dark;
border-radius: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
& > .action-date {
white-space: nowrap;
& > .action {
display: inline-flex;
align-items: center;
justify-content: center;
color: $color-font-bg;
vertical-align: middle;
border-radius: 50%;
width: 24px;
height: 24px;
font-size: 18px;
&.notice {
background-color: $color-notice-medium
}
&.success {
background-color: $color-success-medium;
}
&.warning {
background-color: $color-main-medium;
}
&.alert {
background-color: lighten($color-alert, 5%);
}
}
} }
} }
& > .model { }
overflow: hidden; &:hover > .model-info > vn-icon[icon="filter_alt"] {
text-overflow: ellipsis; display: initial;
white-space: nowrap; }
max-height: 18px; }
.changes-log {
position: relative;
max-width: 100%;
width: 100%;
border-radius: 2px;
overflow: hidden;
& > vn-icon { &:last-child {
margin-bottom: 0;
}
& > .change-info {
display: flex;
justify-content: space-between;
align-items: center;
overflow: hidden;
& > .date {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
& > div {
white-space: nowrap;
& > vn-icon.pit {
@extend %clickable-light; @extend %clickable-light;
vertical-align: middle; vertical-align: middle;
padding: 2px; font-size: 20px;
margin: -2px;
font-size: 18px;
color: $color-font-secondary; color: $color-font-secondary;
float: right;
display: none; display: none;
@include mobile { @include mobile {
display: initial; display: inline-block;
} }
} }
& > .model-value { & > .action {
font-style: italic; display: inline-flex;
} align-items: center;
& > .model-id { justify-content: center;
color: $color-font-secondary; color: $color-font-bg;
font-size: .9rem; vertical-align: middle;
} border-radius: 50%;
} width: 24px;
&:hover > .model > vn-icon { height: 24px;
display: initial; font-size: 18px;
}
}
}
.changes {
overflow: hidden;
background-color: rgba(255, 255, 255, .05);
color: $color-font-light;
position: relative;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-height: 34px;
box-sizing: border-box;
& > vn-icon { &.notice {
@extend %clickable; background-color: $color-notice-medium
float: right; }
position: relative; &.success {
transition-property: transform, background-color; background-color: $color-success-medium;
transition-duration: 150ms; }
margin: -5px; &.warning {
margin-left: 4px; background-color: $color-main-medium;
padding: 1px; }
border-radius: 50%; &.alert {
background-color: lighten($color-alert, 5%);
}
}
}
&:hover vn-icon.pit {
display: inline-block;
}
} }
&.expanded { & > .change-detail {
text-overflow: initial; overflow: hidden;
white-space: initial; background-color: rgba(255, 255, 255, .05);
color: $color-font-light;
position: relative;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-height: 34px;
box-sizing: border-box;
& > vn-icon { & > vn-icon {
transform: rotate(180deg); @extend %clickable;
float: right;
position: relative;
transition-property: transform, background-color;
transition-duration: 150ms;
margin: -5px;
margin-left: 4px;
padding: 1px;
border-radius: 50%;
}
&.expanded {
text-overflow: initial;
white-space: initial;
& > vn-icon {
transform: rotate(180deg);
}
}
& > .no-changes {
font-style: italic;
} }
} }
& > .no-changes { }
font-style: italic; .id-value {
font-size: .9rem;
color: $color-font-secondary;
}
}
.vn-log-instance {
display: block;
& > .loading {
display: flex;
justify-content: center;
}
& > .instance {
min-width: 180px;
max-width: 400px;
& > .header {
background-color: $color-main;
color: $color-font-dark;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin: 0;
}
& > .change-detail {
color: $color-font-light;
} }
} }
} }
vn-log-value > .id-value {
font-size: .9rem;
color: $color-font-secondary;
}

View File

@ -0,0 +1,91 @@
const NotFoundError = require('vn-loopback/util/not-found-error');
module.exports = Self => {
Self.remoteMethod('pitInstance', {
description: 'Gets the status of instance at specific point in time',
accepts: [
{
arg: 'id',
type: 'integer',
description: 'The log id',
required: true
}
],
returns: {
type: [Self],
root: true
},
http: {
path: `/:id/pitInstance`,
verb: 'GET'
}
});
Self.pitInstance = async function(id) {
const log = await Self.findById(id, {
fields: [
'changedModel',
'changedModelId',
'creationDate'
]
});
if (!log)
throw new NotFoundError();
const where = {
changedModel: log.changedModel,
changedModelId: log.changedModelId
};
// Fetch creation and all update logs for record up to requested log
const createdWhere = {
action: 'insert',
creationDate: {lte: log.creationDate}
};
const createdLog = await Self.findOne({
fields: ['id', 'creationDate', 'newInstance'],
where: Object.assign(createdWhere, where),
order: 'creationDate DESC, id DESC'
});
const instance = {};
let logsWhere = {
action: 'update'
};
if (createdLog) {
Object.assign(instance, createdLog.newInstance);
Object.assign(logsWhere, {
creationDate: {between: [
createdLog.creationDate,
log.creationDate
]},
id: {between: [
Math.min(id, createdLog.id),
Math.max(id, createdLog.id)
]}
});
} else {
Object.assign(logsWhere, {
creationDate: {lte: log.creationDate},
id: {lte: id}
});
}
const logs = await Self.find({
fields: ['newInstance'],
where: Object.assign(logsWhere, where),
order: 'creationDate, id'
});
if (!logs.length && !createdLog)
throw new NotFoundError('No logs found for record');
// Merge all logs in order into one instance
for (const log of logs)
Object.assign(instance, log.newInstance);
return instance;
};
};

View File

@ -5,6 +5,7 @@ module.exports = function(Self) {
Self.super_.setup.call(this); Self.super_.setup.call(this);
require('../methods/log/editors')(this); require('../methods/log/editors')(this);
require('../methods/log/models')(this); require('../methods/log/models')(this);
require('../methods/log/pitInstance')(this);
} }
}); });
}; };

View File

@ -294,6 +294,7 @@
"Invalid NIF for VIES": "Invalid NIF for VIES", "Invalid NIF for VIES": "Invalid NIF for VIES",
"Ticket does not exist": "Este ticket no existe", "Ticket does not exist": "Este ticket no existe",
"Ticket is already signed": "Este ticket ya ha sido firmado", "Ticket is already signed": "Este ticket ya ha sido firmado",
"You can only add negative amounts in refund tickets": "Solo se puede añadir cantidades negativas en tickets abono",
"Fecha fuera de rango": "Fecha fuera de rango", "Fecha fuera de rango": "Fecha fuera de rango",
"Error while generating PDF": "Error al generar PDF", "Error while generating PDF": "Error al generar PDF",
"Error when sending mail to client": "Error al enviar el correo al cliente", "Error when sending mail to client": "Error al enviar el correo al cliente",

View File

@ -14,7 +14,7 @@ module.exports = Self => {
Self.validatesPresenceOf('street', { Self.validatesPresenceOf('street', {
message: 'Street cannot be empty' message: 'Street cannot be empty'
}); });
Self.validatesPresenceOf('city', { Self.validatesPresenceOf('city', {
message: 'City cannot be empty' message: 'City cannot be empty'
}); });
@ -282,7 +282,7 @@ module.exports = Self => {
await Self.changeCredit(ctx, finalState, changes); await Self.changeCredit(ctx, finalState, changes);
// Credit management changes // Credit management changes
if (orgData?.rating != changes.rating || orgData?.recommendedCredit != changes.recommendedCredit) if (changes?.rating || changes?.recommendedCredit)
await Self.changeCreditManagement(ctx, finalState, changes); await Self.changeCreditManagement(ctx, finalState, changes);
const oldInstance = {}; const oldInstance = {};

View File

@ -24,20 +24,15 @@ module.exports = Self => {
Self.createPdf = async function(ctx, id, options) { Self.createPdf = async function(ctx, id, options) {
const models = Self.app.models; const models = Self.app.models;
options = typeof options == 'object'
? Object.assign({}, options) : {};
let tx; let tx;
const myOptions = {}; if (!options.transaction)
tx = options.transaction = await Self.beginTransaction({});
if (typeof options == 'object')
Object.assign(myOptions, options);
if (!myOptions.transaction) {
tx = await Self.beginTransaction({});
myOptions.transaction = tx;
}
try { try {
const invoiceOut = await Self.findById(id, {fields: ['hasPdf']}, myOptions); const invoiceOut = await Self.findById(id, {fields: ['hasPdf']}, options);
if (invoiceOut.hasPdf) { if (invoiceOut.hasPdf) {
const canCreatePdf = await models.ACL.checkAccessAcl(ctx, 'InvoiceOut', 'canCreatePdf', 'WRITE'); const canCreatePdf = await models.ACL.checkAccessAcl(ctx, 'InvoiceOut', 'canCreatePdf', 'WRITE');
@ -45,7 +40,7 @@ module.exports = Self => {
throw new UserError(`You don't have enough privileges`); throw new UserError(`You don't have enough privileges`);
} }
await Self.makePdf(id, myOptions); await Self.makePdf(id, options);
if (tx) await tx.commit(); if (tx) await tx.commit();
} catch (err) { } catch (err) {

View File

@ -1,6 +1,5 @@
const fs = require('fs-extra'); const fs = require('fs-extra');
const path = require('path'); const path = require('path');
const UserError = require('vn-loopback/util/user-error');
module.exports = Self => { module.exports = Self => {
Self.remoteMethodCtx('download', { Self.remoteMethodCtx('download', {
@ -37,47 +36,43 @@ module.exports = Self => {
Self.download = async function(ctx, id, options) { Self.download = async function(ctx, id, options) {
const models = Self.app.models; const models = Self.app.models;
const myOptions = {}; options = typeof options == 'object'
? Object.assign({}, options) : {};
if (typeof options == 'object') const pdfFile = await Self.filePath(id, options);
Object.assign(myOptions, options);
const container = await models.InvoiceContainer.container(pdfFile.year);
const rootPath = container.client.root;
const file = {
path: path.join(rootPath, pdfFile.path, pdfFile.name),
contentType: 'application/pdf',
name: pdfFile.name
};
try { try {
const invoiceOut = await models.InvoiceOut.findById(id, { await fs.access(file.path);
fields: ['ref', 'issued'] } catch (error) {
}, myOptions); await Self.createPdf(ctx, id, options);
}
const issued = invoiceOut.issued; let stream = await fs.createReadStream(file.path);
const year = issued.getFullYear().toString(); // XXX: To prevent unhandled ENOENT error
const month = (issued.getMonth() + 1).toString(); // https://stackoverflow.com/questions/17136536/is-enoent-from-fs-createreadstream-uncatchable
const day = issued.getDate().toString(); stream.on('error', err => {
const e = new Error(err.message);
const container = await models.InvoiceContainer.container(year); err.stack = e.stack;
const rootPath = container.client.root; console.error(err);
const src = path.join(rootPath, year, month, day); });
const fileName = `${year}${invoiceOut.ref}.pdf`;
const fileSrc = path.join(src, fileName);
const file = {
path: fileSrc,
contentType: 'application/pdf',
name: fileName
};
if (process.env.NODE_ENV == 'test') {
try { try {
await fs.access(file.path); await fs.access(file.path);
} catch (error) { } catch (error) {
await Self.createPdf(ctx, id, myOptions); stream = null;
} }
const stream = fs.createReadStream(file.path);
return [stream, file.contentType, `filename="${file.name}"`];
} catch (error) {
if (error.code === 'ENOENT')
throw new UserError('The PDF document does not exist');
throw error;
} }
return [stream, file.contentType, `filename="${pdfFile.name}"`];
}; };
}; };

View File

@ -30,15 +30,10 @@ module.exports = Self => {
type: 'number', type: 'number',
description: 'The company id to invoice', description: 'The company id to invoice',
required: true required: true
}, {
arg: 'printerFk',
type: 'number',
description: 'The printer to print',
required: true
} }
], ],
returns: { returns: {
type: 'object', type: 'number',
root: true root: true
}, },
http: { http: {
@ -50,29 +45,22 @@ module.exports = Self => {
Self.invoiceClient = async(ctx, options) => { Self.invoiceClient = async(ctx, options) => {
const args = ctx.args; const args = ctx.args;
const models = Self.app.models; const models = Self.app.models;
const myOptions = {userId: ctx.req.accessToken.userId}; options = typeof options == 'object'
const $t = ctx.req.__; // $translate ? Object.assign({}, options) : {};
const origin = ctx.req.headers.origin; options.userId = ctx.req.accessToken.userId;
let tx; let tx;
if (!options.transaction)
if (typeof options == 'object') tx = options.transaction = await Self.beginTransaction({});
Object.assign(myOptions, options);
if (!myOptions.transaction) {
tx = await Self.beginTransaction({});
myOptions.transaction = tx;
}
const minShipped = Date.vnNew(); const minShipped = Date.vnNew();
minShipped.setFullYear(args.maxShipped.getFullYear() - 1); minShipped.setFullYear(args.maxShipped.getFullYear() - 1);
let invoiceId; let invoiceId;
let invoiceOut;
try { try {
const client = await models.Client.findById(args.clientId, { const client = await models.Client.findById(args.clientId, {
fields: ['id', 'hasToInvoiceByAddress'] fields: ['id', 'hasToInvoiceByAddress']
}, myOptions); }, options);
if (client.hasToInvoiceByAddress) { if (client.hasToInvoiceByAddress) {
await Self.rawSql('CALL ticketToInvoiceByAddress(?, ?, ?, ?)', [ await Self.rawSql('CALL ticketToInvoiceByAddress(?, ?, ?, ?)', [
@ -80,53 +68,57 @@ module.exports = Self => {
args.maxShipped, args.maxShipped,
args.addressId, args.addressId,
args.companyFk args.companyFk
], myOptions); ], options);
} else { } else {
await Self.rawSql('CALL invoiceFromClient(?, ?, ?)', [ await Self.rawSql('CALL invoiceFromClient(?, ?, ?)', [
args.maxShipped, args.maxShipped,
client.id, client.id,
args.companyFk args.companyFk
], myOptions); ], options);
} }
// Make invoice // Check negative bases
const isSpanishCompany = await getIsSpanishCompany(args.companyFk, myOptions);
// Validates ticket nagative base let query =
const hasAnyNegativeBase = await getNegativeBase(myOptions); `SELECT COUNT(*) isSpanishCompany
FROM supplier s
JOIN country c ON c.id = s.countryFk
AND c.code = 'ES'
WHERE s.id = ?`;
const [supplierCompany] = await Self.rawSql(query, [
args.companyFk
], options);
const isSpanishCompany = supplierCompany?.isSpanishCompany;
query = 'SELECT hasAnyNegativeBase() AS base';
const [result] = await Self.rawSql(query, null, options);
const hasAnyNegativeBase = result?.base;
if (hasAnyNegativeBase && isSpanishCompany) if (hasAnyNegativeBase && isSpanishCompany)
throw new UserError('Negative basis'); throw new UserError('Negative basis');
// Invoicing
query = `SELECT invoiceSerial(?, ?, ?) AS serial`; query = `SELECT invoiceSerial(?, ?, ?) AS serial`;
const [invoiceSerial] = await Self.rawSql(query, [ const [invoiceSerial] = await Self.rawSql(query, [
client.id, client.id,
args.companyFk, args.companyFk,
'G' 'G'
], myOptions); ], options);
const serialLetter = invoiceSerial.serial; const serialLetter = invoiceSerial.serial;
query = `CALL invoiceOut_new(?, ?, NULL, @invoiceId)`; query = `CALL invoiceOut_new(?, ?, NULL, @invoiceId)`;
await Self.rawSql(query, [ await Self.rawSql(query, [
serialLetter, serialLetter,
args.invoiceDate args.invoiceDate
], myOptions); ], options);
const [newInvoice] = await Self.rawSql(`SELECT @invoiceId id`, null, myOptions); const [newInvoice] = await Self.rawSql(`SELECT @invoiceId id`, null, options);
if (!newInvoice) if (!newInvoice)
throw new UserError('No tickets to invoice', 'notInvoiced'); throw new UserError('No tickets to invoice', 'notInvoiced');
await Self.rawSql('CALL invoiceOutBooking(?)', [newInvoice.id], myOptions); await Self.rawSql('CALL invoiceOutBooking(?)', [newInvoice.id], options);
invoiceOut = await models.InvoiceOut.findById(newInvoice.id, {
fields: ['id', 'ref', 'clientFk'],
include: {
relation: 'client',
scope: {
fields: ['email', 'isToBeMailed', 'salesPersonFk']
}
}
}, myOptions);
invoiceId = newInvoice.id; invoiceId = newInvoice.id;
if (tx) await tx.commit(); if (tx) await tx.commit();
@ -135,66 +127,6 @@ module.exports = Self => {
throw e; throw e;
} }
try {
await Self.makePdf(invoiceId);
} catch (err) {
console.error(err);
throw new UserError('Error while generating PDF', 'pdfError');
}
if (invoiceOut.client().isToBeMailed) {
try {
ctx.args = {
reference: invoiceOut.ref,
recipientId: args.clientId,
recipient: invoiceOut.client().email
};
await models.InvoiceOut.invoiceEmail(ctx, invoiceOut.ref);
} catch (err) {
const message = $t('Mail not sent', {
clientId: args.clientId,
clientUrl: `${origin}/#!/claim/${args.id}/summary`
});
const salesPersonId = invoiceOut.client().salesPersonFk;
if (salesPersonId)
await models.Chat.sendCheckingPresence(ctx, salesPersonId, message);
throw new UserError('Error when sending mail to client', 'mailNotSent');
}
} else {
const query = `
CALL vn.report_print(
'invoice',
?,
account.myUser_getId(),
JSON_OBJECT('refFk', ?),
'normal'
);`;
await models.InvoiceOut.rawSql(query, [args.printerFk, invoiceOut.ref]);
}
return invoiceId; return invoiceId;
}; };
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(*) isSpanishCompany
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.isSpanishCompany;
}
}; };

View File

@ -11,20 +11,17 @@ module.exports = Self => {
type: 'string', type: 'string',
required: true, required: true,
http: {source: 'path'} http: {source: 'path'}
}, }, {
{
arg: 'recipient', arg: 'recipient',
type: 'string', type: 'string',
description: 'The recipient email', description: 'The recipient email',
required: true, required: true,
}, }, {
{
arg: 'replyTo', arg: 'replyTo',
type: 'string', type: 'string',
description: 'The sender email to reply to', description: 'The sender email to reply to',
required: false required: false
}, }, {
{
arg: 'recipientId', arg: 'recipientId',
type: 'number', type: 'number',
description: 'The recipient id to send to the recipient preferred language', description: 'The recipient id to send to the recipient preferred language',
@ -43,16 +40,13 @@ module.exports = Self => {
Self.invoiceEmail = async(ctx, reference) => { Self.invoiceEmail = async(ctx, reference) => {
const args = Object.assign({}, ctx.args); const args = Object.assign({}, ctx.args);
const {InvoiceOut} = Self.app.models;
const params = { const params = {
recipient: args.recipient, recipient: args.recipient,
lang: ctx.req.getLocale() lang: ctx.req.getLocale()
}; };
const invoiceOut = await InvoiceOut.findOne({ const invoiceOut = await Self.findOne({
where: { where: {ref: reference}
ref: reference
}
}); });
delete args.ctx; delete args.ctx;

View File

@ -0,0 +1,87 @@
const UserError = require('vn-loopback/util/user-error');
module.exports = Self => {
Self.remoteMethodCtx('makePdfAndNotify', {
description: 'Create invoice PDF and send it to client',
accessType: 'WRITE',
accepts: [
{
arg: 'id',
type: 'number',
description: 'The invoice id',
required: true,
http: {source: 'path'}
}, {
arg: 'printerFk',
type: 'number',
description: 'The printer to print',
required: true
}
],
http: {
path: '/:id/makePdfAndNotify',
verb: 'POST'
}
});
Self.makePdfAndNotify = async function(ctx, id, printerFk) {
const models = Self.app.models;
options = typeof options == 'object'
? Object.assign({}, options) : {};
options.userId = ctx.req.accessToken.userId;
try {
await Self.makePdf(id, options);
} catch (err) {
console.error(err);
throw new UserError('Error while generating PDF', 'pdfError');
}
const invoiceOut = await Self.findById(id, {
fields: ['ref', 'clientFk'],
include: {
relation: 'client',
scope: {
fields: ['id', 'email', 'isToBeMailed', 'salesPersonFk']
}
}
}, options);
const ref = invoiceOut.ref;
const client = invoiceOut.client();
if (client.isToBeMailed) {
try {
ctx.args = {
reference: ref,
recipientId: client.id,
recipient: client.email
};
await Self.invoiceEmail(ctx, ref);
} catch (err) {
const origin = ctx.req.headers.origin;
const message = ctx.req.__('Mail not sent', {
clientId: client.id,
clientUrl: `${origin}/#!/claim/${id}/summary`
});
const salesPersonId = client.salesPersonFk;
if (salesPersonId)
await models.Chat.sendCheckingPresence(ctx, salesPersonId, message);
throw new UserError('Error when sending mail to client', 'mailNotSent');
}
} else {
const query = `
CALL vn.report_print(
'invoice',
?,
account.myUser_getId(),
JSON_OBJECT('refFk', ?),
'normal'
);`;
await Self.rawSql(query, [printerFk, ref], options);
}
};
};

View File

@ -1,28 +0,0 @@
const models = require('vn-loopback/server/server').models;
const fs = require('fs-extra');
describe('InvoiceOut download()', () => {
const userId = 9;
const invoiceId = 1;
const ctx = {
req: {
accessToken: {userId: userId},
headers: {origin: 'http://localhost:5000'},
}
};
it('should return the downloaded file 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);
spyOn(models.InvoiceOut, 'createPdf').and.returnValue(new Promise(resolve => resolve(true)));
const result = await models.InvoiceOut.download(ctx, invoiceId);
expect(result[1]).toEqual('application/pdf');
expect(result[2]).toMatch(/filename="\d{4}T1111111.pdf"/);
});
});

View File

@ -2,6 +2,9 @@
"InvoiceOut": { "InvoiceOut": {
"dataSource": "vn" "dataSource": "vn"
}, },
"InvoiceOutConfig": {
"dataSource": "vn"
},
"InvoiceOutSerial": { "InvoiceOutSerial": {
"dataSource": "vn" "dataSource": "vn"
}, },

View File

@ -0,0 +1,22 @@
{
"name": "InvoiceOutConfig",
"base": "VnModel",
"options": {
"mysql": {
"table": "invoiceOutConfig"
}
},
"properties": {
"id": {
"id": true,
"type": "number",
"description": "Identifier"
},
"parallelism": {
"type": "number",
"required": true
}
}
}

View File

@ -1,4 +1,5 @@
const print = require('vn-print'); const print = require('vn-print');
const path = require('path');
module.exports = Self => { module.exports = Self => {
require('../methods/invoiceOut/filter')(Self); require('../methods/invoiceOut/filter')(Self);
@ -12,6 +13,7 @@ module.exports = Self => {
require('../methods/invoiceOut/createManualInvoice')(Self); require('../methods/invoiceOut/createManualInvoice')(Self);
require('../methods/invoiceOut/clientsToInvoice')(Self); require('../methods/invoiceOut/clientsToInvoice')(Self);
require('../methods/invoiceOut/invoiceClient')(Self); require('../methods/invoiceOut/invoiceClient')(Self);
require('../methods/invoiceOut/makePdfAndNotify')(Self);
require('../methods/invoiceOut/refund')(Self); require('../methods/invoiceOut/refund')(Self);
require('../methods/invoiceOut/invoiceEmail')(Self); require('../methods/invoiceOut/invoiceEmail')(Self);
require('../methods/invoiceOut/exportationPdf')(Self); require('../methods/invoiceOut/exportationPdf')(Self);
@ -22,20 +24,31 @@ module.exports = Self => {
require('../methods/invoiceOut/negativeBases')(Self); require('../methods/invoiceOut/negativeBases')(Self);
require('../methods/invoiceOut/negativeBasesCsv')(Self); require('../methods/invoiceOut/negativeBasesCsv')(Self);
Self.makePdf = async function(id, options) { Self.filePath = async function(id, options) {
const fields = ['id', 'hasPdf', 'ref', 'issued']; const fields = ['ref', 'issued'];
const invoiceOut = await Self.findById(id, {fields}, options); const invoiceOut = await Self.findById(id, {fields}, options);
const invoiceReport = new print.Report('invoice', {
reference: invoiceOut.ref
});
const buffer = await invoiceReport.toPdfStream();
const issued = invoiceOut.issued; const issued = invoiceOut.issued;
const year = issued.getFullYear().toString(); const year = issued.getFullYear().toString();
const month = (issued.getMonth() + 1).toString(); const month = (issued.getMonth() + 1).toString();
const day = issued.getDate().toString(); const day = issued.getDate().toString();
const fileName = `${year}${invoiceOut.ref}.pdf`; return {
path: path.join(year, month, day),
name: `${year}${invoiceOut.ref}.pdf`,
year
};
};
Self.makePdf = async function(id, options) {
const fields = ['id', 'hasPdf', 'ref'];
const invoiceOut = await Self.findById(id, {fields}, options);
const invoiceReport = new print.Report('invoice', {
reference: invoiceOut.ref
});
const buffer = await invoiceReport.toPdfStream();
const pdfFile = await Self.filePath(id, options);
// Store invoice // Store invoice
@ -46,8 +59,8 @@ module.exports = Self => {
if (process.env.NODE_ENV !== 'test') { if (process.env.NODE_ENV !== 'test') {
await print.storage.write(buffer, { await print.storage.write(buffer, {
type: 'invoice', type: 'invoice',
path: `${year}/${month}/${day}`, path: pdfFile.path,
fileName: fileName fileName: pdfFile.name
}); });
} }
}; };

View File

@ -1,7 +1,6 @@
<vn-card <vn-card
ng-if="$ctrl.status" ng-if="$ctrl.status"
class="vn-w-lg vn-pa-md" class="status vn-w-lg vn-pa-md">
style="height: 80px; display: flex; align-items: center; justify-content: center; gap: 20px;">
<vn-spinner <vn-spinner
enable="$ctrl.status != 'done'"> enable="$ctrl.status != 'done'">
</vn-spinner> </vn-spinner>
@ -20,8 +19,15 @@
Ended process Ended process
</span> </span>
</div> </div>
<div ng-if="$ctrl.nAddresses" class="text-caption text-secondary"> <div ng-if="$ctrl.nAddresses">
{{$ctrl.percentage | percentage: 0}} ({{$ctrl.addressNumber}} {{'of' | translate}} {{$ctrl.nAddresses}}) <div class="text-caption text-secondary">
{{$ctrl.percentage | percentage: 0}}
({{$ctrl.addressNumber}} <span translate>of</span> {{$ctrl.nAddresses}})
</div>
<div class="text-caption text-secondary">
{{$ctrl.nPdfs}} <span translate>of</span> {{$ctrl.totalPdfs}}
<span translate>PDFs</span>
</div>
</div> </div>
</div> </div>
</vn-card> </vn-card>
@ -141,7 +147,7 @@
<vn-submit <vn-submit
ng-if="$ctrl.invoicing" ng-if="$ctrl.invoicing"
label="Stop" label="Stop"
ng-click="$ctrl.stopInvoicing()"> ng-click="$ctrl.status = 'stopping'">
</vn-submit> </vn-submit>
</vn-vertical> </vn-vertical>
</form> </form>

View File

@ -9,30 +9,27 @@ class Controller extends Section {
Object.assign(this, { Object.assign(this, {
maxShipped: new Date(date.getFullYear(), date.getMonth(), 0), maxShipped: new Date(date.getFullYear(), date.getMonth(), 0),
clientsToInvoice: 'all', clientsToInvoice: 'all',
companyFk: this.vnConfig.companyFk,
parallelism: 1
}); });
this.$http.get('UserConfigs/getUserConfig') const params = {companyFk: this.companyFk};
.then(res => {
this.companyFk = res.data.companyFk;
this.getInvoiceDate(this.companyFk);
});
}
getInvoiceDate(companyFk) {
const params = {companyFk: companyFk};
this.fetchInvoiceDate(params);
}
fetchInvoiceDate(params) {
this.$http.get('InvoiceOuts/getInvoiceDate', {params}) this.$http.get('InvoiceOuts/getInvoiceDate', {params})
.then(res => { .then(res => {
this.minInvoicingDate = res.data.issued ? new Date(res.data.issued) : null; this.minInvoicingDate = res.data.issued ? new Date(res.data.issued) : null;
this.invoiceDate = this.minInvoicingDate; this.invoiceDate = this.minInvoicingDate;
}); });
}
stopInvoicing() { const filter = {fields: ['parallelism']};
this.status = 'stopping'; this.$http.get('InvoiceOutConfigs/findOne', {filter})
.then(res => {
if (res.data.parallelism)
this.parallelism = res.data.parallelism;
})
.catch(res => {
if (res.status == 404) return;
throw res;
});
} }
makeInvoice() { makeInvoice() {
@ -70,8 +67,11 @@ class Controller extends Section {
if (!this.addresses.length) if (!this.addresses.length)
throw new UserError(`There aren't tickets to invoice`); throw new UserError(`There aren't tickets to invoice`);
this.nRequests = 0;
this.nPdfs = 0;
this.totalPdfs = 0;
this.addressIndex = 0; this.addressIndex = 0;
return this.invoiceOut(); this.invoiceClient();
}) })
.catch(err => this.handleError(err)); .catch(err => this.handleError(err));
} catch (err) { } catch (err) {
@ -85,8 +85,11 @@ class Controller extends Section {
throw err; throw err;
} }
invoiceOut() { invoiceClient() {
if (this.addressIndex == this.addresses.length || this.status == 'stopping') { if (this.nRequests == this.parallelism || this.isInvoicing) return;
if (this.addressIndex >= this.addresses.length || this.status == 'stopping') {
if (this.nRequests) return;
this.invoicing = false; this.invoicing = false;
this.status = 'done'; this.status = 'done';
return; return;
@ -95,36 +98,27 @@ class Controller extends Section {
this.status = 'invoicing'; this.status = 'invoicing';
const address = this.addresses[this.addressIndex]; const address = this.addresses[this.addressIndex];
this.currentAddress = address; this.currentAddress = address;
this.isInvoicing = true;
const params = { const params = {
clientId: address.clientId, clientId: address.clientId,
addressId: address.id, addressId: address.id,
invoiceDate: this.invoiceDate, invoiceDate: this.invoiceDate,
maxShipped: this.maxShipped, maxShipped: this.maxShipped,
companyFk: this.companyFk, companyFk: this.companyFk
printerFk: this.printerFk,
}; };
this.$http.post(`InvoiceOuts/invoiceClient`, params) this.$http.post(`InvoiceOuts/invoiceClient`, params)
.then(() => this.invoiceNext()) .then(res => {
this.isInvoicing = false;
if (res.data)
this.makePdfAndNotify(res.data, address);
this.invoiceNext();
})
.catch(res => { .catch(res => {
this.isInvoicing = false;
if (res.status >= 400 && res.status < 500) { if (res.status >= 400 && res.status < 500) {
const error = res.data?.error; this.invoiceError(address, res);
let isWarning;
const filter = {
where: {
id: address.clientId
}
};
switch (error?.code) {
case 'pdfError':
case 'mailNotSent':
isWarning = true;
break;
}
const message = error?.message || res.message;
this.errors.unshift({address, message, isWarning});
this.invoiceNext(); this.invoiceNext();
} else { } else {
this.invoicing = false; this.invoicing = false;
@ -136,7 +130,27 @@ class Controller extends Section {
invoiceNext() { invoiceNext() {
this.addressIndex++; this.addressIndex++;
this.invoiceOut(); this.invoiceClient();
}
makePdfAndNotify(invoiceId, address) {
this.nRequests++;
this.totalPdfs++;
const params = {printerFk: this.printerFk};
this.$http.post(`InvoiceOuts/${invoiceId}/makePdfAndNotify`, params)
.catch(res => {
this.invoiceError(address, res, true);
})
.finally(() => {
this.nPdfs++;
this.nRequests--;
this.invoiceClient();
});
}
invoiceError(address, res, isWarning) {
const message = res.data?.error?.message || res.message;
this.errors.unshift({address, message, isWarning});
} }
get nAddresses() { get nAddresses() {

View File

@ -10,6 +10,7 @@ Build packaging tickets: Generando tickets de embalajes
Address id: Id dirección Address id: Id dirección
Printer: Impresora Printer: Impresora
of: de of: de
PDFs: PDFs
Client: Cliente Client: Cliente
Current client id: Id cliente actual Current client id: Id cliente actual
Invoicing client: Facturando cliente Invoicing client: Facturando cliente

View File

@ -1,17 +1,21 @@
@import "variables"; @import "variables";
vn-invoice-out-global-invoicing{ vn-invoice-out-global-invoicing {
h5 {
h5{
color: $color-primary; color: $color-primary;
} }
.status {
height: 80px;
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
}
#error { #error {
line-break: normal; line-break: normal;
overflow-wrap: break-word; overflow-wrap: break-word;
white-space: normal; white-space: normal;
} }
} }

View File

@ -110,4 +110,53 @@ describe('sale updateQuantity()', () => {
throw e; throw e;
} }
}); });
it('should throw an error if the quantity is negative and it is not a refund ticket', async() => {
const ctx = {
req: {
accessToken: {userId: 1},
headers: {origin: 'localhost:5000'},
__: () => {}
}
};
const saleId = 17;
const newQuantity = -10;
const tx = await models.Sale.beginTransaction({});
let error;
try {
const options = {transaction: tx};
await models.Sale.updateQuantity(ctx, saleId, newQuantity, options);
await tx.rollback();
} catch (e) {
await tx.rollback();
error = e;
}
expect(error).toEqual(new Error('You can only add negative amounts in refund tickets'));
});
it('should update a negative quantity when is a ticket refund', async() => {
const tx = await models.Sale.beginTransaction({});
const saleId = 13;
const newQuantity = -10;
try {
const options = {transaction: tx};
await models.Sale.updateQuantity(ctx, saleId, newQuantity, options);
const modifiedLine = await models.Sale.findOne({where: {id: saleId}, fields: ['quantity']}, options);
expect(modifiedLine.quantity).toEqual(newQuantity);
await tx.rollback();
} catch (e) {
await tx.rollback();
throw e;
}
});
}); });

View File

@ -68,6 +68,13 @@ module.exports = Self => {
if (newQuantity > sale.quantity && !isRoleAdvanced) if (newQuantity > sale.quantity && !isRoleAdvanced)
throw new UserError('The new quantity should be smaller than the old one'); throw new UserError('The new quantity should be smaller than the old one');
const ticketRefund = await models.TicketRefund.findOne({
where: {refundTicketFk: sale.ticketFk},
fields: ['id']}
, myOptions);
if (newQuantity < 0 && !ticketRefund)
throw new UserError('You can only add negative amounts in refund tickets');
const oldQuantity = sale.quantity; const oldQuantity = sale.quantity;
const result = await sale.updateAttributes({quantity: newQuantity}, myOptions); const result = await sale.updateAttributes({quantity: newQuantity}, myOptions);

View File

@ -81,17 +81,15 @@ module.exports = Self => {
throw new UserError('You must delete all the buy requests first'); throw new UserError('You must delete all the buy requests first');
// removes item shelvings // removes item shelvings
if (hasItemShelvingSales && isSalesAssistant) { const promises = [];
const promises = []; for (let sale of sales) {
for (let sale of sales) { if (sale.itemShelvingSale()) {
if (sale.itemShelvingSale()) { const itemShelvingSale = sale.itemShelvingSale();
const itemShelvingSale = sale.itemShelvingSale(); const destroyedShelving = models.ItemShelvingSale.destroyById(itemShelvingSale.id, myOptions);
const destroyedShelving = models.ItemShelvingSale.destroyById(itemShelvingSale.id, myOptions); promises.push(destroyedShelving);
promises.push(destroyedShelving);
}
} }
await Promise.all(promises);
} }
await Promise.all(promises);
// Remove ticket greuges // Remove ticket greuges
const ticketGreuges = await models.Greuge.find({where: {ticketFk: id}}, myOptions); const ticketGreuges = await models.Greuge.find({where: {ticketFk: id}}, myOptions);

View File

@ -1,10 +1,10 @@
module.exports = function(Self) { module.exports = function(Self) {
Self.observe('after save', async function(ctx) { Self.observe('after save', async function(ctx) {
const instance = ctx.instance; const instance = ctx.data || ctx.instance;
const models = Self.app.models; const models = Self.app.models;
const options = ctx.options; const options = ctx.options;
if (!instance.sectorFk || !instance.labelerFk) return; if (!instance?.sectorFk || !instance?.labelerFk) return;
const sector = await models.Sector.findById(instance.sectorFk, { const sector = await models.Sector.findById(instance.sectorFk, {
fields: ['mainPrinterFk'] fields: ['mainPrinterFk']

View File

@ -16,18 +16,12 @@
"type": "number" "type": "number"
}, },
"trainFk": { "trainFk": {
"type": "number", "type": "number"
"required": true
}, },
"itemPackingTypeFk": { "itemPackingTypeFk": {
"type": "string", "type": "string"
"required": true
}, },
"warehouseFk": { "warehouseFk": {
"type": "number",
"required": true
},
"sectorFk": {
"type": "number" "type": "number"
}, },
"labelerFk": { "labelerFk": {

View File

@ -3,27 +3,35 @@
data="absenceTypes" data="absenceTypes"
auto-load="true"> auto-load="true">
</vn-crud-model> </vn-crud-model>
<div class="vn-w-lg"> <div ng-if="$ctrl.worker.hasWorkCenter">
<vn-card class="vn-pa-sm calendars"> <div class="vn-w-lg">
<vn-icon ng-if="::$ctrl.isSubordinate" icon="info" color-marginal <vn-card class="vn-pa-sm calendars">
vn-tooltip="To start adding absences, click an absence type from the right menu and then on the day you want to add an absence"> <vn-icon ng-if="::$ctrl.isSubordinate" icon="info" color-marginal
</vn-icon> vn-tooltip="To start adding absences, click an absence type from the right menu and then on the day you want to add an absence">
<vn-calendar </vn-icon>
ng-repeat="month in $ctrl.months" <vn-calendar
data="$ctrl.events" ng-repeat="month in $ctrl.months"
default-date="month" data="$ctrl.events"
format-day="$ctrl.formatDay($day, $element)" default-date="month"
display-controls="false" format-day="$ctrl.formatDay($day, $element)"
hide-contiguous="true" display-controls="false"
hide-year="true" hide-contiguous="true"
on-selection="$ctrl.onSelection($event, $days)"> hide-year="true"
</vn-calendar> on-selection="$ctrl.onSelection($event, $days)">
</vn-card> </vn-calendar>
</vn-card>
</div>
</div>
<div
ng-if="!$ctrl.worker.hasWorkCenter"
class="bg-title"
translate>
Autonomous worker
</div> </div>
<vn-side-menu side="right"> <vn-side-menu side="right">
<div class="vn-pa-md"> <div class="vn-pa-md">
<div class="totalBox vn-mb-sm" style="text-align: center;"> <div class="totalBox vn-mb-sm" style="text-align: center;">
<h6>{{'Contract' | translate}} #{{$ctrl.businessId}}</h6> <h6>{{'Contract' | translate}} #{{$ctrl.card.worker.hasWorkCenter}}</h6>
<div> <div>
{{'Used' | translate}} {{$ctrl.contractHolidays.holidaysEnjoyed || 0}} {{'Used' | translate}} {{$ctrl.contractHolidays.holidaysEnjoyed || 0}}
{{'of' | translate}} {{$ctrl.contractHolidays.totalHolidays || 0}} {{'days' | translate}} {{'of' | translate}} {{$ctrl.contractHolidays.totalHolidays || 0}} {{'days' | translate}}
@ -63,7 +71,6 @@
ng-model="$ctrl.businessId" ng-model="$ctrl.businessId"
search-function="{businessFk: $search}" search-function="{businessFk: $search}"
value-field="businessFk" value-field="businessFk"
show-field="businessFk"
order="businessFk DESC" order="businessFk DESC"
limit="5"> limit="5">
<tpl-item> <tpl-item>
@ -103,3 +110,4 @@
message="This item will be deleted" message="This item will be deleted"
question="Are you sure you want to continue?"> question="Are you sure you want to continue?">
</vn-confirm> </vn-confirm>

View File

@ -64,8 +64,7 @@ class Controller extends Section {
set worker(value) { set worker(value) {
this._worker = value; this._worker = value;
if (value && value.hasWorkCenter) {
if (value) {
this.getIsSubordinate(); this.getIsSubordinate();
this.getActiveContract(); this.getActiveContract();
} }

View File

@ -74,7 +74,7 @@ describe('Worker', () => {
let yesterday = new Date(today.getTime()); let yesterday = new Date(today.getTime());
yesterday.setDate(yesterday.getDate() - 1); yesterday.setDate(yesterday.getDate() - 1);
controller.worker = {id: 1107}; controller.worker = {id: 1107, hasWorkCenter: true};
expect(controller.getIsSubordinate).toHaveBeenCalledWith(); expect(controller.getIsSubordinate).toHaveBeenCalledWith();
expect(controller.getActiveContract).toHaveBeenCalledWith(); expect(controller.getActiveContract).toHaveBeenCalledWith();

View File

@ -11,4 +11,5 @@ Choose an absence type from the right menu: Elige un tipo de ausencia desde el m
To start adding absences, click an absence type from the right menu and then on the day you want to add an absence: Para empezar a añadir ausencias, haz clic en un tipo de ausencia desde el menu de la derecha y después en el día que quieres añadir la ausencia To start adding absences, click an absence type from the right menu and then on the day you want to add an absence: Para empezar a añadir ausencias, haz clic en un tipo de ausencia desde el menu de la derecha y después en el día que quieres añadir la ausencia
You can just add absences within the current year: Solo puedes añadir ausencias dentro del año actual You can just add absences within the current year: Solo puedes añadir ausencias dentro del año actual
Current day: Día actual Current day: Día actual
Paid holidays: Vacaciones pagadas Paid holidays: Vacaciones pagadas
Autonomous worker: Trabajador autónomo

View File

@ -34,6 +34,10 @@ class Controller extends ModuleCard {
this.$http.get(`Workers/${this.$params.id}`, {filter}) this.$http.get(`Workers/${this.$params.id}`, {filter})
.then(res => this.worker = res.data); .then(res => this.worker = res.data);
this.$http.get(`Workers/${this.$params.id}/activeContract`)
.then(res => {
if (res.data) this.worker.hasWorkCenter = res.data.workCenterFk;
});
} }
} }

View File

@ -4,106 +4,114 @@
filter="::$ctrl.filter" filter="::$ctrl.filter"
data="$ctrl.hours"> data="$ctrl.hours">
</vn-crud-model> </vn-crud-model>
<vn-card class="vn-pa-lg vn-w-lg"> <div ng-if="$ctrl.worker.hasWorkCenter">
<vn-table model="model" auto-load="false"> <vn-card class="vn-pa-lg vn-w-lg">
<vn-thead> <vn-table model="model" auto-load="false">
<vn-tr> <vn-thead>
<vn-td ng-repeat="weekday in $ctrl.weekDays" center> <vn-tr>
<div class="weekday" translate>{{::$ctrl.weekdayNames[$index].name}}</div> <vn-td ng-repeat="weekday in $ctrl.weekDays" center>
<div> <div class="weekday" translate>{{::$ctrl.weekdayNames[$index].name}}</div>
<span>{{::weekday.dated | date: 'dd'}}</span>
<span title="{{::weekday.dated | date: 'MMMM' | translate}}" translate>
{{::weekday.dated | date: 'MMMM'}}
</span>
</div>
<vn-chip
title="{{::weekday.event.name}}"
ng-class="{invisible: !weekday.event}">
<vn-avatar
ng-style="::{backgroundColor: weekday.event.color}">
</vn-avatar>
<div> <div>
{{::weekday.event.name}} <span>{{::weekday.dated | date: 'dd'}}</span>
<span title="{{::weekday.dated | date: 'MMMM' | translate}}" translate>
{{::weekday.dated | date: 'MMMM'}}
</span>
</div> </div>
</vn-chip>
</vn-td>
</vn-tr>
</vn-thead>
<vn-tbody>
<vn-tr>
<vn-td ng-repeat="weekday in $ctrl.weekDays" class="hours vn-pa-none" expand>
<section ng-repeat="hour in weekday.hours">
<vn-icon
icon="{{
::hour.direction == 'in' ? 'arrow_forward' : 'arrow_back'
}}"
title="{{
::(hour.direction == 'in' ? 'In' : 'Out') | translate
}}"
ng-class="::{'invisible': hour.direction == 'middle'}">
</vn-icon>
<vn-chip <vn-chip
ng-class="::{'colored': hour.manual, 'clickable': true}" title="{{::weekday.event.name}}"
removable="::hour.manual" ng-class="{invisible: !weekday.event}">
on-remove="$ctrl.showDeleteDialog($event, hour)" <vn-avatar
ng-click="$ctrl.edit($event, hour)" ng-style="::{backgroundColor: weekday.event.color}">
> </vn-avatar>
<prepend> <div>
<vn-icon icon="edit" {{::weekday.event.name}}
vn-tooltip="Edit"> </div>
</vn-icon>
</prepend>
{{::hour.timed | date: 'HH:mm'}}
</vn-chip> </vn-chip>
</section> </vn-td>
</vn-td> </vn-tr>
</vn-tr> </vn-thead>
</vn-tbody> <vn-tbody>
<vn-tfoot> <vn-tr>
<vn-tr> <vn-td ng-repeat="weekday in $ctrl.weekDays" class="hours vn-pa-none" expand>
<vn-td ng-repeat="weekday in $ctrl.weekDays" center> <section ng-repeat="hour in weekday.hours">
{{$ctrl.formatHours(weekday.workedHours)}} h. <vn-icon
</vn-td> icon="{{
</vn-tr> ::hour.direction == 'in' ? 'arrow_forward' : 'arrow_back'
<vn-tr> }}"
<vn-td center ng-repeat="weekday in $ctrl.weekDays"> title="{{
<vn-icon-button ::(hour.direction == 'in' ? 'In' : 'Out') | translate
icon="add_circle" }}"
vn-tooltip="Add time" ng-class="::{'invisible': hour.direction == 'middle'}">
ng-click="$ctrl.showAddTimeDialog(weekday)"> </vn-icon>
</vn-icon-button> <vn-chip
</vn-td> ng-class="::{'colored': hour.manual, 'clickable': true}"
</vn-tr> removable="::hour.manual"
</vn-tfoot> on-remove="$ctrl.showDeleteDialog($event, hour)"
</vn-table> ng-click="$ctrl.edit($event, hour)"
</vn-card> >
<prepend>
<vn-icon icon="edit"
vn-tooltip="Edit">
</vn-icon>
</prepend>
{{::hour.timed | date: 'HH:mm'}}
</vn-chip>
</section>
</vn-td>
</vn-tr>
</vn-tbody>
<vn-tfoot>
<vn-tr>
<vn-td ng-repeat="weekday in $ctrl.weekDays" center>
{{$ctrl.formatHours(weekday.workedHours)}} h.
</vn-td>
</vn-tr>
<vn-tr>
<vn-td center ng-repeat="weekday in $ctrl.weekDays">
<vn-icon-button
icon="add_circle"
vn-tooltip="Add time"
ng-click="$ctrl.showAddTimeDialog(weekday)">
</vn-icon-button>
</vn-td>
</vn-tr>
</vn-tfoot>
</vn-table>
</vn-card>
<vn-button-bar ng-show="$ctrl.state" class="vn-w-lg"> <vn-button-bar ng-show="$ctrl.state" class="vn-w-lg">
<vn-button <vn-button
label="Satisfied" label="Satisfied"
disabled="$ctrl.state == 'CONFIRMED'" disabled="$ctrl.state == 'CONFIRMED'"
ng-if="$ctrl.isHimSelf" ng-if="$ctrl.isHimSelf"
ng-click="$ctrl.isSatisfied()"> ng-click="$ctrl.isSatisfied()">
</vn-button> </vn-button>
<vn-button <vn-button
label="Not satisfied" label="Not satisfied"
disabled="$ctrl.state == 'REVISE'" disabled="$ctrl.state == 'REVISE'"
ng-if="$ctrl.isHimSelf" ng-if="$ctrl.isHimSelf"
ng-click="reason.show()"> ng-click="reason.show()">
</vn-button> </vn-button>
<vn-button <vn-button
label="Reason" label="Reason"
ng-if="$ctrl.reason && ($ctrl.isHimSelf || $ctrl.isHr)" ng-if="$ctrl.reason && ($ctrl.isHimSelf || $ctrl.isHr)"
ng-click="reason.show()"> ng-click="reason.show()">
</vn-button> </vn-button>
<vn-button <vn-button
label="Resend" label="Resend"
ng-click="sendEmailConfirmation.show()" ng-click="sendEmailConfirmation.show()"
class="right" class="right"
vn-tooltip="Resend email of this week to the user" vn-tooltip="Resend email of this week to the user"
ng-show="::$ctrl.isHr"> ng-show="::$ctrl.isHr">
</vn-button> </vn-button>
</vn-button-bar> </vn-button-bar>
</div>
<div
ng-if="!$ctrl.worker.hasWorkCenter"
class="bg-title"
translate>
Autonomous worker
</div>
<vn-side-menu side="right"> <vn-side-menu side="right">
<div class="vn-pa-md"> <div class="vn-pa-md">

View File

@ -151,6 +151,7 @@ class Controller extends Section {
} }
getAbsences() { getAbsences() {
if (!this.worker.hasWorkerCenter) return;
const fullYear = this.started.getFullYear(); const fullYear = this.started.getFullYear();
let params = { let params = {
workerFk: this.$params.id, workerFk: this.$params.id,

View File

@ -16,6 +16,10 @@ describe('Component vnWorkerTimeControl', () => {
$scope = $rootScope.$new(); $scope = $rootScope.$new();
$element = angular.element('<vn-worker-time-control></vn-worker-time-control>'); $element = angular.element('<vn-worker-time-control></vn-worker-time-control>');
controller = $componentController('vnWorkerTimeControl', {$element, $scope}); controller = $componentController('vnWorkerTimeControl', {$element, $scope});
controller.worker = {
hasWorkerCenter: true
};
})); }));
describe('date() setter', () => { describe('date() setter', () => {

View File

@ -63,12 +63,12 @@ class Email extends Component {
await getAttachments(componentPath, component.attachments); await getAttachments(componentPath, component.attachments);
if (component.components) if (component.components)
await getSubcomponentAttachments(component) await getSubcomponentAttachments(component);
} }
} }
} }
await getSubcomponentAttachments(instance) await getSubcomponentAttachments(instance);
if (this.attachments) if (this.attachments)
await getAttachments(this.path, this.attachments); await getAttachments(this.path, this.attachments);

View File

@ -20,14 +20,18 @@ module.exports = {
options.to = config.app.senderEmail; options.to = config.app.senderEmail;
} }
let res;
let error; let error;
return this.transporter.sendMail(options).catch(err => { try {
res = await this.transporter.sendMail(options);
} catch (err) {
error = err; error = err;
throw err; throw err;
}).finally(async() => { } finally {
await this.mailLog(options, error); await this.mailLog(options, error);
}); }
return res;
}, },
async mailLog(options, error) { async mailLog(options, error) {