Merge branch 'requirement-specs-custom-variables'
authorSven Schöling <s.schoeling@linet-services.de>
Thu, 5 Mar 2015 12:43:16 +0000 (13:43 +0100)
committerSven Schöling <s.schoeling@linet-services.de>
Thu, 5 Mar 2015 12:43:16 +0000 (13:43 +0100)
24 files changed:
SL/BackgroundJob/ALL.pm
SL/BackgroundJob/FailedBackgroundJobsReport.pm [new file with mode: 0644]
SL/Controller/CustomVariableConfig.pm
SL/Controller/Helper/ParseFilter.pm
SL/Controller/RequirementSpec.pm
SL/DB/CustomVariableConfig.pm
SL/DB/Helper/CustomVariables.pm
SL/DB/Helper/Filtered.pm
SL/DB/Manager/RequirementSpec.pm
SL/DB/MetaSetup/Default.pm
SL/DB/RequirementSpec.pm
bin/mozilla/io.pl
css/requirement_spec.css
locale/de/all
scripts/locales.pl
sql/Pg-upgrade2/custom_variables_delete_via_trigger_requirement_specs.sql [new file with mode: 0644]
sql/Pg-upgrade2/defaults_sales_purchase_order_show_ship_missing_column.sql [new file with mode: 0644]
t/controllers/helpers/parse_filter.t
templates/webpages/client_config/_features.html
templates/webpages/common/render_cvar_filter_input.html [new file with mode: 0644]
templates/webpages/failed_background_jobs_report/email.txt [new file with mode: 0644]
templates/webpages/requirement_spec/_filter.html
templates/webpages/requirement_spec/_form.html
templates/webpages/requirement_spec/_show_basic_settings.html

index 84c55ee..ba9ce83 100644 (file)
@@ -6,6 +6,7 @@ use SL::BackgroundJob::Base;
 use SL::BackgroundJob::BackgroundJobCleanup;
 use SL::BackgroundJob::CleanBackgroundJobHistory;
 use SL::BackgroundJob::CreatePeriodicInvoices;
+use SL::BackgroundJob::FailedBackgroundJobsReport;
 
 1;
 
diff --git a/SL/BackgroundJob/FailedBackgroundJobsReport.pm b/SL/BackgroundJob/FailedBackgroundJobsReport.pm
new file mode 100644 (file)
index 0000000..e0bea8b
--- /dev/null
@@ -0,0 +1,155 @@
+package SL::BackgroundJob::FailedBackgroundJobsReport;
+
+use strict;
+use utf8;
+
+use parent qw(SL::BackgroundJob::Base);
+
+use SL::DB::BackgroundJobHistory;
+use SL::Locale::String;
+use SL::Mailer;
+
+use Rose::Object::MakeMethods::Generic (
+  scalar => [ qw(data start_time entries) ],
+);
+
+
+sub create_job {
+  # Don't create this job by default
+  1;
+}
+
+sub check_config {
+  my ($self) = @_;
+
+  die "No »recipients« specified" unless @{ $self->data->{recipients} || [] };
+  die "No »from« specified"       unless $self->data->{from};
+  die "No »subject« specified"    unless $self->data->{subject};
+
+  return $self;
+}
+
+sub send_email {
+  my ($self) = @_;
+
+  return 1 unless @{ $self->entries };
+
+  my $template  = Template->new({
+    INTERPOLATE => 0,
+    EVAL_PERL   => 0,
+    ABSOLUTE    => 1,
+    CACHE_SIZE  => 0,
+  }) || die("Could not create Template instance");
+
+  my $file_name = $self->data->{template} || 'templates/webpages/failed_background_jobs_report/email.txt';
+  my $body;
+  $template->process($file_name, { SELF => $self }, \$body);
+  $body = Encode::decode('utf-8', $body);
+
+  Mailer->new(
+    from         => $self->data->{from},
+    to           => join(', ', @{ $self->data->{recipients} }),
+    subject      => $self->data->{subject},
+    content_type => 'text/plain',
+    charset      => 'utf-8',
+    message      => $body,
+  )->send;
+
+  return $self;
+}
+
+sub load_failed_entries {
+  my ($self) = @_;
+
+  $self->start_time(DateTime->now_local->subtract(days => 1));
+  $self->entries([ @{ SL::DB::Manager::BackgroundJobHistory->get_all(
+    sort_by  => 'run_at ASC',
+    where    => [
+      status => SL::DB::BackgroundJobHistory::FAILURE(),
+      run_at => { ge => $self->start_time },
+    ],
+  )}]);
+
+  return $self;
+}
+
+sub run {
+  my ($self, $db_obj) = @_;
+
+  $self->data($db_obj->data_as_hash);
+
+  $self
+    ->check_config
+    ->load_failed_entries
+    ->send_email;
+
+  return 1;
+}
+
+1;
+__END__
+
+=pod
+
+=encoding utf8
+
+=head1 NAME
+
+SL::BackgroundJob::FailedBackgroundJobsReport - A background job
+checking for failed jobs and reporting them via email
+
+=head1 OVERVIEW
+
+This background's job is to watch over other background jobs. It will
+determine when it has run last and look for job history entries that
+have failed between the last run and the current time.
+
+If that search yields results then an email will be sent listing the
+jobs that failed and the error messages they produced. The template
+used for the email's body defaults to the file
+C<templates/webpages/failed_background_jobs_report/email.txt> but can
+be overridden in the configuration.
+
+This background job is not active by default. You have to add and
+configure it manually.
+
+=head1 CONFIGURATION
+
+This background job requires configuration data stored in its data
+member. This is supposed to be a YAML-encoded hash of the following
+options:
+
+=over 4
+
+=item * C<from> – required; the sender's email address used in the
+mail headers
+
+=item * C<recipients> – required; an array of email addresses for the
+recipients
+
+=item * C<subject> – required; the email's subject
+
+=item * C<template> – optional; a file name pointing to the template
+file used for the email's body. This defaults to
+C<templates/webpages/failed_background_jobs_report/email.txt>.
+
+=back
+
+Here's an example of how this data looks like:
+
+  ---
+  from: kivitendo@meine.firma
+  recipients:
+    - johanna.admin@meine.firma
+  subject: Fehlgeschlagene kivitendo-Jobs der letzten 24h
+  template: templates/mycompany/faileed_background_jobs_email.txt
+
+=head1 BUGS
+
+Nothing here yet.
+
+=head1 AUTHOR
+
+Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>
+
+=cut
index ed7e844..456fe71 100644 (file)
@@ -158,12 +158,13 @@ sub init_translated_types {
 sub init_modules {
   my ($self, %params) = @_;
 
-  return [
-    { module => 'CT',       description => t8('Customers and vendors')          },
-    { module => 'Contacts', description => t8('Contact persons')                },
-    { module => 'IC',       description => t8('Parts, services and assemblies') },
-    { module => 'Projects', description => t8('Projects')                       },
-  ];
+  return [ sort { $a->{description}->translated cmp $b->{description}->translated } (
+    { module => 'CT',               description => t8('Customers and vendors')          },
+    { module => 'Contacts',         description => t8('Contact persons')                },
+    { module => 'IC',               description => t8('Parts, services and assemblies') },
+    { module => 'Projects',         description => t8('Projects')                       },
+    { module => 'RequirementSpecs', description => t8('Requirement Specs')              },
+  )];
 }
 
 sub create_or_update {
index 491eb63..c38974e 100644 (file)
@@ -43,10 +43,10 @@ sub parse_filter {
     _add_uniq($objects, $_) for @$auto_objects;
   }
 
-  my $query = _parse_filter($flattened, $objects, %params);
-
   _launder_keys($filter, $params{launder_to}) unless $params{no_launder};
 
+  my $query = _parse_filter($flattened, $objects, %params);
+
   return
     ($query   && @$query   ? (query => $query) : ()),
     ($objects && @$objects ? ( with_objects => [ uniq @$objects ]) : ());
@@ -149,25 +149,27 @@ sub _dispatch_custom_filters {
   die 'unrecognized filters' if $key =~ /:/;
 
   my @tokens     = split /\./, $key;
-  my $last_token = pop @tokens;
   my $curr_class = $class->object_class;
 
-  for my $token (@tokens) {
+  # find first token which is not a relationship
+  my $i = 0;
+   while ($i < $#tokens) {
     eval {
-      $curr_class = $curr_class->meta->relationship($token)->class;
-      1;
+      $curr_class = $curr_class->meta->relationship($tokens[$_])->class;
+      ++$i;
     } or do {
-      require Carp;
-      Carp::croak("Could not resolve the relationship '$token' in '$key' while building the filter request");
+      last;
     }
   }
 
   my $manager    = $curr_class->meta->convention_manager->auto_manager_class_name;
-  my $obj_path   = join '.', @tokens;
-  my $obj_prefix = join '.', @tokens, '';
+  my $obj_path   = join '.', @tokens[0..$i-1];
+  my $obj_prefix = join '.', @tokens[0..$i-1], '';
+  my $key_token  = $tokens[$i];
+  my @additional_tokens = @tokens[$i+1..$#tokens];
 
   if ($manager->can('filter')) {
-    ($key, $value, my $obj) = $manager->filter($last_token, $value, $obj_prefix, $obj_path);
+    ($key, $value, my $obj) = $manager->filter($key_token, $value, $obj_prefix, $obj_path, @additional_tokens);
     _add_uniq($with_objects, $obj) if $obj;
   } else {
     _add_uniq($with_objects, $obj_path) if $obj_path;
index 3158cd0..1e36181 100644 (file)
@@ -30,7 +30,7 @@ use Rose::Object::MakeMethods::Generic
 (
   scalar                  => [ qw(requirement_spec_item visible_item visible_section) ],
   'scalar --get_set_init' => [ qw(requirement_spec customers types statuses complexities risks projects project_types project_statuses default_project_type default_project_status copy_source js
-                                  current_text_block_output_position models time_based_units html_template) ],
+                                  current_text_block_output_position models time_based_units html_template cvar_configs includeable_cvar_configs include_cvars) ],
 );
 
 __PACKAGE__->run_before('setup');
@@ -323,7 +323,7 @@ sub setup {
 
   $::auth->assert('requirement_spec_edit');
   $::request->{layout}->use_stylesheet("${_}.css") for qw(jquery.contextMenu requirement_spec);
-  $::request->{layout}->use_javascript("${_}.js")  for qw(jquery.jstree jquery/jquery.contextMenu jquery/jquery.hotkeys requirement_spec ckeditor/ckeditor ckeditor/adapters/jquery autocomplete_part);
+  $::request->{layout}->use_javascript("${_}.js")  for qw(jquery.jstree jquery/jquery.contextMenu jquery/jquery.hotkeys requirement_spec ckeditor/ckeditor ckeditor/adapters/jquery autocomplete_part autocomplete_customer);
   $self->init_visible_section;
 
   return 1;
@@ -340,6 +340,7 @@ sub init_risks                  { SL::DB::Manager::RequirementSpecRisk->get_all_
 sub init_statuses               { SL::DB::Manager::RequirementSpecStatus->get_all_sorted      }
 sub init_time_based_units       { SL::DB::Manager::Unit->time_based_units                     }
 sub init_types                  { SL::DB::Manager::RequirementSpecType->get_all_sorted        }
+sub init_cvar_configs           { SL::DB::Manager::CustomVariableConfig->get_all_sorted(where => [ module => 'RequirementSpecs' ]) }
 
 sub init_customers {
   my ($self) = @_;
@@ -365,6 +366,17 @@ sub init_current_text_block_output_position {
   $self->current_text_block_output_position($::form->{current_content_type} !~ m/^(?:text-blocks|tb)-(front|back)/ ? -1 : $1 eq 'front' ? 0 : 1);
 }
 
+sub init_includeable_cvar_configs {
+  my ($self) = @_;
+  return [ grep { $_->includeable } @{ $self->cvar_configs } ];
+}
+
+sub init_include_cvars {
+  my ($self) = @_;
+  return $::form->{include_cvars} if $::form->{include_cvars} && (ref($::form->{include_cvars}) eq 'HASH');
+  return { map { ($_->name => ($_->includeable && $_->included_by_default)) } @{ $self->cvar_configs } };
+}
+
 #
 # helpers
 #
@@ -373,9 +385,24 @@ sub create_or_update {
   my $self   = shift;
   my $is_new = !$self->requirement_spec->id;
   my $params = delete($::form->{requirement_spec}) || { };
+  my $cvars  = delete($::form->{cvars})            || { };
+
+  # Forcefully make it clear to Rose which custom_variables exist (or don't), so that the ones added with »add_custom_variables« are visible when calling »custom_variables«.
+  if ($is_new) {
+    $params->{custom_variables} = [];
+  } else {
+    $self->requirement_spec->custom_variables;
+  }
 
   $self->requirement_spec->assign_attributes(%{ $params });
 
+  foreach my $var (@{ $self->requirement_spec->cvars_by_config }) {
+    my $value = $cvars->{ $var->config->name };
+    $value    = $::form->parse_amount(\%::myconfig, $value) if $var->config->type eq 'number';
+
+    $var->value($value);
+  }
+
   my $title  = $is_new && $self->requirement_spec->is_template ? t8('Create a new requirement spec template')
              : $is_new                                         ? t8('Create a new requirement spec')
              :            $self->requirement_spec->is_template ? t8('Edit requirement spec template')
@@ -396,7 +423,7 @@ sub create_or_update {
     if ($self->copy_source) {
       $self->requirement_spec($self->copy_source->create_copy(%{ $params }));
     } else {
-      $self->requirement_spec->save;
+      $self->requirement_spec->save(cascade => 1);
     }
   })) {
     $::lxdebug->message(LXDebug::WARN(), "Error: " . $db->error);
@@ -458,7 +485,21 @@ sub prepare_report {
     );
   }
 
-  map { $column_defs{$_}->{text} ||= $::locale->text( $self->models->get_sort_spec->{$_}->{title} ) } keys %column_defs;
+  $column_defs{$_}->{text} ||= $::locale->text( $self->models->get_sort_spec->{$_}->{title} ) for keys %column_defs;
+
+  if (!$is_template) {
+    my %cvar_column_defs = map {
+      my $cfg = $_;
+      (('cvar_' . $cfg->name) => {
+        sub     => sub { my $var = $_[0]->cvar_by_name($cfg->name); $var ? $var->value_as_text : '' },
+        text    => $cfg->description,
+        visible => $self->include_cvars->{ $cfg->name } ? 1 : 0,
+      })
+    } @{ $self->includeable_cvar_configs };
+
+    push @columns, map { 'cvar_' . $_->name } @{ $self->includeable_cvar_configs };
+    %column_defs = (%column_defs, %cvar_column_defs);
+  }
 
   $report->set_options(
     std_column_visibility => 1,
index 5404ebf..ab7f83b 100644 (file)
@@ -103,4 +103,24 @@ sub type_dependent_default_value {
   return (any { $_ eq $self->default_value } @{ $self->processed_options }) ? $self->default_value : $self->processed_options->[0];
 }
 
+sub value_col {
+  my ($self) = @_;
+
+  my $type = $self->type;
+
+  return {
+    bool      => 'bool_value',
+    timestamp => 'timestamp_value',
+    date      => 'timestamp_value',
+    number    => 'number_value',
+    integer   => 'number_value',
+    customer  => 'number_value',
+    vendor    => 'number_value',
+    part      => 'number_value',
+    text      => 'text_value',
+    textfield => 'text_value',
+    select    => 'text_value'
+  }->{$type};
+}
+
 1;
index 7136855..97837a2 100644 (file)
@@ -28,6 +28,7 @@ sub import {
   make_cvar_by_name($caller_package, %params);
   make_cvar_as_hashref($caller_package, %params);
   make_cvar_value_parser($caller_package, %params);
+  make_cvar_custom_filter($caller_package, %params);
 }
 
 sub save_meta_info {
@@ -223,6 +224,132 @@ sub _get_primary_key_column {
   return $column_name;
 }
 
+sub make_cvar_custom_filter {
+  my ($caller_package, %params) = @_;
+
+  my $manager    = $caller_package->meta->convention_manager->auto_manager_class_name;
+
+  return unless $manager->can('filter');
+
+  $manager->add_filter_specs(
+    cvar => sub {
+      my ($key, $value, $prefix, $config_id) = @_;
+      my $config = SL::DB::Manager::CustomVariableConfig->find_by(id => $config_id);
+
+      if (!$config) {
+        die "invalid config_id in $caller_package\::cvar custom filter: $config_id";
+      }
+
+      if ($config->module != $params{module}) {
+        die "invalid config_id in $caller_package\::cvar custom filter: expected module $params{module} - got @{[ $config->module ]}";
+      }
+
+      my @filter;
+      if ($config->type eq 'bool') {
+        @filter = $value ? ($config->value_col => 1) : (or => [ $config->value_col => undef, $config->value_col => 0 ]);
+      } else {
+        @filter = ($config->value_col => $value);
+      }
+
+      my (%query, %bind_vals);
+      ($query{customized}, $bind_vals{customized}) = Rose::DB::Object::QueryBuilder::build_select(
+        dbh                  => $config->dbh,
+        select               => 'trans_id',
+        tables               => [ 'custom_variables' ],
+        columns              => { custom_variables => [ qw(trans_id config_id text_value number_value bool_value timestamp_value sub_module) ] },
+        query                => [
+          config_id          => $config_id,
+          sub_module         => $params{sub_module},
+          @filter,
+        ],
+        query_is_sql         => 1,
+      );
+
+      if ($config->type eq 'bool') {
+        if ($value) {
+          @filter = (
+            '!default_value' => undef,
+            '!default_value' => '',
+            default_value    => '1',
+          );
+
+        } else {
+          @filter = (
+            or => [
+              default_value => '0',
+              default_value => '',
+              default_value => undef,
+            ],
+          );
+        }
+
+      } else {
+        @filter = (
+          '!default_value' => undef,
+          '!default_value' => '',
+          default_value    => $value,
+        );
+      }
+
+
+      my $conversion  = $config->type =~ m{^(?:date|timestamp)$}       ? $config->type
+                      : $config->type =~ m{^(?:customer|vendor|part)$} ? 'integer'
+                      : $config->type eq 'number'                      ? 'numeric'
+                      :                                                  '';
+
+      ($query{config}, $bind_vals{config}) = Rose::DB::Object::QueryBuilder::build_select(
+        dbh                => $config->dbh,
+        select             => 'id',
+        tables             => [ 'custom_variable_configs' ],
+        columns            => { custom_variable_configs => [ qw(id default_value) ] },
+        query              => [
+          id               => $config->id,
+          @filter,
+        ],
+        query_is_sql       => 1,
+      );
+
+      $query{config} =~ s{ (?<! NOT\( ) default_value (?! \s*is\s+not\s+null) }{default_value::${conversion}}x if $conversion;
+
+      ($query{not_customized}, $bind_vals{not_customized}) = Rose::DB::Object::QueryBuilder::build_select(
+        dbh          => $config->dbh,
+        select       => 'trans_id',
+        tables       => [ 'custom_variables' ],
+        columns      => { custom_variables => [ qw(trans_id config_id sub_module) ] },
+        query        => [
+          config_id  => $config_id,
+          sub_module => $params{sub_module},
+        ],
+        query_is_sql => 1,
+      );
+
+      foreach my $key (keys %query) {
+        # remove rose aliases. query builder sadly is not reentrant, and will reuse the same aliases. :(
+        $query{$key} =~ s{\bt\d+(?:\.)?\b}{}g;
+
+        # manually inline the values. again, rose doen't know how to handly bind params in subqueries :(
+        $query{$key} =~ s{\?}{ $config->dbh->quote(shift @{ $bind_vals{$key} }) }xeg;
+
+        $query{$key} =~ s{\n}{ }g;
+      }
+
+      my $qry_config = "EXISTS (" . $query{config} . ")";
+
+      my @result = (
+        'or' => [
+          $prefix . 'id'   => [ \$query{customized} ],
+          and              => [
+            "!${prefix}id" => [ \$query{not_customized}  ],
+            \$qry_config,
+          ]
+        ],
+      );
+
+      return @result;
+    }
+  );
+}
+
 1;
 
 __END__
@@ -371,6 +498,18 @@ some way then you have to call this function manually. For example:
 
 =back
 
+=head1 INSTALLED MANAGER METHODS
+
+=over 4
+
+=item Custom filter for GetModels
+
+If the Manager for the calling C<SL::DB::Object> has included the helper L<SL::DB::Helper::Filtered>, a custom filter for cvars will be added to the specs, with the following syntax:
+
+  filter.cvar.$config_id
+
+=back
+
 =head1 AUTHOR
 
 Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>,
index 3532949..5d18d8e 100644 (file)
@@ -10,13 +10,13 @@ our @EXPORT = qw (filter add_filter_specs);
 my %filter_spec;
 
 sub filter {
-  my ($class, $key, $value, $prefix, $path) = @_;
+  my ($class, $key, $value, $prefix, $path, @additional_tokens) = @_;
 
   my $filters = _get_filters($class);
 
   return ($prefix . $key, $value, $path) unless $filters->{$key};
 
-  return $filters->{$key}->($key, $value, $prefix);
+  return $filters->{$key}->($key, $value, $prefix, @additional_tokens);
 }
 
 sub _get_filters {
index fecd9d3..fa35de0 100644 (file)
@@ -5,6 +5,7 @@ use strict;
 use SL::DB::Helper::Manager;
 use base qw(SL::DB::Helper::Manager);
 
+use SL::DB::Helper::Filtered;
 use SL::DB::Helper::Paginated;
 use SL::DB::Helper::Sorted;
 
index bf9e89c..accab29 100644 (file)
@@ -84,6 +84,7 @@ __PACKAGE__->meta->columns(
   rmanumber                                 => { type => 'text' },
   sales_delivery_order_show_delete          => { type => 'boolean', default => 'true' },
   sales_order_show_delete                   => { type => 'boolean', default => 'true' },
+  sales_purchase_order_ship_missing_column  => { type => 'boolean', default => 'false' },
   sdonumber                                 => { type => 'text' },
   sepa_creditor_id                          => { type => 'text' },
   servicenumber                             => { type => 'text' },
index bc68c17..1cc1771 100644 (file)
@@ -9,6 +9,10 @@ use Rose::DB::Object::Helpers;
 use SL::DB::MetaSetup::RequirementSpec;
 use SL::DB::Manager::RequirementSpec;
 use SL::DB::Helper::AttrDuration;
+use SL::DB::Helper::CustomVariables (
+  module      => 'RequirementSpecs',
+  cvars_alias => 1,
+);
 use SL::DB::Helper::LinkedRecords;
 use SL::Locale::String;
 use SL::Util qw(_hashify);
@@ -171,6 +175,11 @@ sub _copy_from {
                              %attributes);
   }
 
+  # Copy custom variables.
+  foreach my $var (@{ $source->cvars_by_config }) {
+    $self->cvar_by_name($var->config->name)->value($var->value);
+  }
+
   my %paste_template_result;
 
   # Clone text blocks and pictures.
@@ -203,7 +212,7 @@ sub _copy_from {
   $self->$accessor($paste_template_result{parts});
 
   # Save new object -- we need its ID for the items.
-  $self->save;
+  $self->save(cascade => 1);
 
   my %id_to_clone;
 
index 1acd6a0..b2b6979 100644 (file)
@@ -139,6 +139,7 @@ sub display_row {
   my $is_quotation       = $form->{type} =~ /_quotation$/;
   my $is_invoice         = $form->{type} =~ /invoice/;
   my $is_s_p_order       = (first { $_ eq $form->{type} } qw(sales_order purchase_order));
+  my $show_ship_missing  = $is_s_p_order && $::instance_conf->get_sales_purchase_order_ship_missing_column;
 
   if ($is_delivery_order) {
     if ($form->{type} eq 'sales_delivery_order') {
@@ -159,6 +160,7 @@ sub display_row {
     {  id => 'partnumber',    width => 8,     value => $locale->text('Number'),               display => 1, },
     {  id => 'description',   width => 30,    value => $locale->text('Part Description'),     display => 1, },
     {  id => 'ship',          width => 5,     value => $locale->text('Delivered'),            display => $is_s_p_order, },
+    {  id => 'ship_missing',  width => 5,     value => $locale->text('Not delivered'),        display => $show_ship_missing, },
     {  id => 'qty',           width => 5,     value => $locale->text('Qty'),                  display => 1, },
     {  id => 'price_factor',  width => 5,     value => $locale->text('Price Factor'),         display => !$is_delivery_order, },
     {  id => 'unit',          width => 5,     value => $locale->text('Unit'),                 display => 1, },
@@ -200,7 +202,7 @@ sub display_row {
   my $deliverydate  = $locale->text('Required by');
 
   # special alignings
-  my %align  = map { $_ => 'right' } qw(qty ship right discount linetotal stock_in_out weight);
+  my %align  = map { $_ => 'right' } qw(qty ship right discount linetotal stock_in_out weight ship_missing);
   my %nowrap = map { $_ => 1 }       qw(description unit);
 
   $form->{marge_total}           = 0;
@@ -309,6 +311,11 @@ sub display_row {
       $ship_qty          /= ( $all_units->{$form->{"unit_$i"}}->{factor} || 1 );
 
       $column_data{ship}  = $form->format_amount(\%myconfig, $form->round_amount($ship_qty, 2) * 1) . ' ' . $form->{"unit_$i"};
+
+      my $ship_missing_qty    = $form->{"qty_$i"} - $ship_qty;
+      my $ship_missing_amount = $form->round_amount($ship_missing_qty * $form->{"sellprice_$i"} * (100 - $form->{"discount_$i"}) / 100 / $price_factor, 2);
+
+      $column_data{ship_missing} = $form->format_amount(\%myconfig, $ship_missing_qty) . ' ' . $form->{"unit_$i"} . '; ' . $form->format_amount(\%myconfig, $ship_missing_amount, $decimalplaces);
     }
 
     my $sellprice_value = $form->format_amount(\%myconfig, $form->{"sellprice_$i"}, $decimalplaces);
@@ -2065,4 +2072,3 @@ sub _make_record {
 
   return $obj;
 }
-
index 9dbd0d6..36dfeb5 100644 (file)
@@ -3,7 +3,7 @@
 /* ------------------------------------------------------------ */
 
 input.rs_input_field, select.rs_input_field,
-table.rs_input_field input, table.rs_input_field select {
+table.rs_input_field input[type=text], table.rs_input_field input[type=password], table.rs_input_field select {
   width: 300px;
 }
 
index ffd4375..7128dd0 100755 (executable)
@@ -1278,6 +1278,7 @@ $self->{texts} = {
   'If disabled purchase invoices can only be created by conversion from existing requests for quotations, purchase orders and purchase delivery orders.' => 'Falls deaktiviert, so können Einkaufsrechnungen nur durch Umwandlung aus bestehenden Preisanfragen, Lieferantenaufträgen und Einkaufslieferscheinen angelegt werden.',
   'If disabled sales orders cannot be converted into sales invoices directly.' => 'Falls deaktiviert, so können Verkaufsaufträge nicht direkt in Verkaufsrechnungen umgewandelt werden.',
   'If disabled sales quotations cannot be converted into sales invoices directly.' => 'Falls deaktiviert, so können Verkaufsangebote nicht direkt in Verkaufsrechnungen umgewandelt werden.',
+  'If enabled a column will be shown in sales and purchase orders that lists both the amount and the value not shipped yet for each item.' => 'Falls eingeschaltet, wird für jede Position in Auftragsbestätigungen und Lieferantenaufträgen eine Spalte mit noch nicht gelieferter Menge und Wert angezeigt.',
   'If enabled only those projects that are assigned to the currently selected customer are offered for selection in sales records.' => 'Wenn eingeschaltet, so werden in Verkaufsbelegen nur diejenigen Projekte zur Auswahl angeboten, die dem aktuell ausgewählten Kunden zugewiesen wurden.',
   'If enabled purchase and sales records cannot be saved if no transaction description has been entered.' => 'Wenn angeschaltet, so können Einkaufs- und Verkaufsbelege nicht gespeichert werden, solange keine Vorgangsbezeichnung eingegeben wurde.',
   'If missing then the start date will be used.' => 'Falls es fehlt, so wird die erste Rechnung für das Startdatum erzeugt.',
@@ -2310,6 +2311,7 @@ $self->{texts} = {
   'Show the weights of articles and the total weight in orders, invoices and delivery notes?' => 'Sollen Warengewichte und Gesamtgewicht in Aufträgen, Rechnungen und Lieferscheinen angezeigt werden?',
   'Show weights'                => 'Gewichte anzeigen',
   'Show your TODO list after logging in' => 'Aufgabenliste nach dem Anmelden anzeigen',
+  'Show »not delivered qty/value« column in sales and purchase orders' => 'Spalte »Nicht gelieferte Menge/Wert« in Aufträgen anzeigen',
   'Signature'                   => 'Unterschrift',
   'Since bin is not enforced in the parts data, please specify a bin where goods without a specified bin will be put.' => 'Da Lagerpl&auml;tze kein Pflichtfeld sind, geben Sie bitte einen Lagerplatz an, in dem Waren ohne spezifizierten Lagerplatz eingelagert werden sollen.',
   'Single quotes'               => 'Einfache Anführungszeichen',
index 51db82f..eb1d81b 100755 (executable)
@@ -49,7 +49,8 @@ our $missing     = {};
 our @lost        = ();
 
 my %ignore_unused_templates = (
-  map { $_ => 1 } qw(ct/testpage.html generic/autocomplete.html oe/periodic_invoices_email.txt part/testpage.html t/render.html t/render.js task_server/failure_notification_email.txt)
+  map { $_ => 1 } qw(ct/testpage.html generic/autocomplete.html oe/periodic_invoices_email.txt part/testpage.html t/render.html t/render.js task_server/failure_notification_email.txt
+                     failed_background_jobs_report/email.txt)
 );
 
 my (%referenced_html_files, %locale, %htmllocale, %alllocales, %cached, %submit, %jslocale);
diff --git a/sql/Pg-upgrade2/custom_variables_delete_via_trigger_requirement_specs.sql b/sql/Pg-upgrade2/custom_variables_delete_via_trigger_requirement_specs.sql
new file mode 100644 (file)
index 0000000..da7de2a
--- /dev/null
@@ -0,0 +1,18 @@
+-- @tag: custom_variables_delete_via_trigger_requirement_specs
+-- @description: Benutzerdefinierte Variablen von Pflichtenheften via Trigger löschen
+-- @depends: custom_variables_delete_via_trigger requirement_specs
+CREATE OR REPLACE FUNCTION delete_requirement_spec_custom_variables_trigger() RETURNS trigger AS $$
+  BEGIN
+    DELETE FROM custom_variables WHERE (sub_module = '' OR sub_module IS NULL)
+                                   AND trans_id = OLD.id
+                                   AND (SELECT module FROM custom_variable_configs WHERE id = config_id) = 'RequirementSpecs';
+
+    RETURN OLD;
+  END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS delete_requirement_spec_custom_variables ON requirement_specs;
+
+CREATE TRIGGER delete_requirement_spec_custom_variables
+BEFORE DELETE ON requirement_specs
+FOR EACH ROW EXECUTE PROCEDURE delete_requirement_spec_custom_variables_trigger();
diff --git a/sql/Pg-upgrade2/defaults_sales_purchase_order_show_ship_missing_column.sql b/sql/Pg-upgrade2/defaults_sales_purchase_order_show_ship_missing_column.sql
new file mode 100644 (file)
index 0000000..afbac81
--- /dev/null
@@ -0,0 +1,7 @@
+-- @tag: defaults_sales_purchase_order_show_ship_missing_column
+-- @description: Mandantenkonfiguration: Optionale Spalte »Nicht gelieferte Menge« in Auftragsbestätigungen und Lieferantenaufträgen
+-- @depends: release_3_1_0
+
+ALTER TABLE defaults ADD COLUMN sales_purchase_order_ship_missing_column BOOLEAN;
+UPDATE defaults SET sales_purchase_order_ship_missing_column = FALSE;
+ALTER TABLE defaults ALTER COLUMN sales_purchase_order_ship_missing_column SET DEFAULT FALSE;
index 583b26c..a53c8c4 100644 (file)
@@ -1,6 +1,6 @@
 use lib 't';
 
-use Test::More tests => 31;
+use Test::More tests => 36;
 use Test::Deep;
 use Data::Dumper;
 
@@ -179,6 +179,44 @@ test {
   }
 }, 'deep laundering, check for laundered hash', target => 'launder', launder_to => { };
 
+test {
+  part => {
+   'sellprice:number' => '2',
+   'sellprice:number::' => 'le',
+  }
+}, {
+  part => {
+   'sellprice:number' => '2',
+   'sellprice:number::' => 'le',
+  }
+}, 'laundering of indirect filters does not alter', target => 'filter', launder_to => { };
+
+test {
+  part => {
+   'sellprice:number' => '2',
+   'sellprice:number::' => 'le',
+  }
+}, {
+  part => {
+    'sellprice_number' => '2',
+    'sellprice_number__' => 'le',
+  }
+}, 'laundering of indirect filters', target => 'launder', launder_to => { };
+
+test {
+  part => {
+   'sellprice:number' => '2',
+   'sellprice:number::' => 'le',
+  }
+}, {
+  part => {
+    'sellprice:number' => '2',
+    'sellprice:number::' => 'le',
+    'sellprice_number' => '2',
+    'sellprice_number__' => 'le',
+  }
+}, 'laundering of indirect filters - inplace', target => 'filter';
+
 ### bug: sub objects
 
 test {
@@ -335,3 +373,27 @@ test {
     ]
   ]
 }, ':multi with complex tokenizing';
+
+# test tokenizing for custom filters by monkeypatching a custom filter into Part
+SL::DB::Manager::Part->add_filter_specs(
+  test => sub {
+    my ($key, $value, $prefix, @additional) = @_;
+    return "$prefix$key" => { @additional, $value };
+  }
+);
+
+test {
+  'part.test.what' => 2,
+}, {
+  query => [
+    'part.test' => { 'what', 2 },
+  ]
+}, 'additional tokens', class => 'SL::DB::Manager::OrderItem';
+
+test {
+  'part.test.what:substr::ilike' => 2,
+}, {
+  query => [
+    'part.test' => { 'what', { ilike => '%2%' } },
+  ]
+}, 'additional tokens + filters + methods', class => 'SL::DB::Manager::OrderItem';
index f081ca2..14f6d10 100644 (file)
    <td>[% LxERP.t8('If disabled purchase invoices can only be created by conversion from existing requests for quotations, purchase orders and purchase delivery orders.') %]</td>
   </tr>
 
+  <tr>
+   <td align="right">[% LxERP.t8("Show »not delivered qty/value« column in sales and purchase orders") %]</td>
+   <td>[% L.yes_no_tag("defaults.sales_purchase_order_ship_missing_column", SELF.defaults.sales_purchase_order_ship_missing_column) %]</td>
+   <td>[% LxERP.t8("If enabled a column will be shown in sales and purchase orders that lists both the amount and the value not shipped yet for each item.") %]</td>
+  </tr>
+
   <tr><td class="listheading" colspan="4">[% LxERP.t8("E-mail") %]</td></tr>
 
   <tr>
diff --git a/templates/webpages/common/render_cvar_filter_input.html b/templates/webpages/common/render_cvar_filter_input.html
new file mode 100644 (file)
index 0000000..4ec984b
--- /dev/null
@@ -0,0 +1,35 @@
+[%- USE HTML -%][%- USE L -%][%- USE LxERP -%][%- USE T8 -%]
+[%- SET id__    = cvar_cfg.id
+        name__  = 'filter.cvar.' _ id__
+        value__ = filter.cvar.$id__ %]
+[%- IF cvar_cfg.type == 'bool' %]
+ [%- L.select_tag(name__, [ '', [ 1, LxERP.t8('Yes') ], [ 0, LxERP.t8('No') ] ], default=value__, class=cvar_class) %]
+
+[%- ELSIF cvar_cfg.type == 'number' %]
+ [% L.select_tag(name__ _ '::', [ [ 'eq', '==' ], [ 'ne', '=/=' ], [ 'gt', '>' ], [ 'ge', '>=' ], [ 'lt', '<' ], [ 'le', '<=' ] ], default=filter.cvar.item(cvar_cfg.id _ '__')) %]
+ [% L.input_tag(name__, value__, class=cvar_class) %]
+
+[%- ELSIF cvar_cfg.type == 'date' %]
+ [% L.select_tag(name__ _ '::', [ [ 'eq', '==' ], [ 'ne', '=/=' ], [ 'gt', '>' ], [ 'ge', '>=' ], [ 'lt', '<' ], [ 'le', '<=' ] ], default=filter.cvar.item(cvar_cfg.id _ '__')) %]
+ [% L.date_tag(name__, value__, class=cvar_class) %]
+
+[% ELSIF cvar_cfg.type == 'select' %]
+ [% options__ = [ '' ];
+    options__ = options__.import(cvar_cfg.processed_options);
+    L.select_tag(name__, options__, default=value__, class=cvar_class) %]
+
+[% ELSIF cvar_cfg.type == 'customer' %]
+ [%- L.customer_vendor_picker(name__, value__, type='customer', class=cvar_class) %]
+
+[% ELSIF cvar_cfg.type == 'vendor' %]
+ [%- L.customer_vendor_picker(name__, value__, type='vendor', class=cvar_class) %]
+
+[% ELSIF cvar_cfg.type == 'part' %]
+ [%- L.part_picker(name__, value__, class=cvar_class) %]
+
+[%- ELSE %]
+ [% SET value_name__ = id__ _ '_substr__ilike'
+        value__      = filter.cvar.$value_name__ %]
+ [%- L.input_tag(name__ _ ':substr::ilike', value__, maxlength=cvar_cfg.maxlength, class=cvar_class) %]
+
+[%- END %]
diff --git a/templates/webpages/failed_background_jobs_report/email.txt b/templates/webpages/failed_background_jobs_report/email.txt
new file mode 100644 (file)
index 0000000..506a9b7
--- /dev/null
@@ -0,0 +1,12 @@
+Hallo,
+
+die folgenden Hintergrundjobs sind seit [% SELF.start_time.to_kivitendo %] [% SELF.start_time.to_kivitendo_time %] ausgeführt worden und schlugen fehl:
+
+[%- FOREACH entry = SELF.entries %]
+Paketname:     [% entry.package_name %]
+Ausgeführt um: [% entry.run_at.to_kivitendo %] [% entry.run_at.to_kivitendo_time %]
+Fehler:        [% entry.error_col %]
+[% UNLESS loop.last %]============================================================[% END %]
+[%- END %]
+Gruß,
+kivitendo
index 19c03da..201709f 100644 (file)
   [%- L.hidden_tag("is_template", is_template) %]
 
   <p>
-   <table class="rs_input_field">
+   <table>
     <tr>
      <th align="right">[% LxERP.t8("Title") %]</th>
-     <td>[% L.input_tag('filter.title:substr::ilike', filter.title_substr__ilike) %]</td>
+     <td>[% L.input_tag('filter.title:substr::ilike', filter.title_substr__ilike, class="rs_input_field") %]</td>
     </tr>
 
 [%- UNLESS is_template %]
     <tr>
      <th align="right">[% LxERP.t8("Customer") %]</th>
-     <td>[% L.input_tag('filter.customer.name:substr::ilike', filter.customer.name_substr__ilike) %]</td>
+     <td>[% L.input_tag('filter.customer.name:substr::ilike', filter.customer.name_substr__ilike, class="rs_input_field") %]</td>
     </tr>
 
     <tr>
      <th align="right">[% LxERP.t8("Customer Number") %]</th>
-     <td>[% L.input_tag('filter.customer.customernumber:substr::ilike', filter.customer.customernumber_substr__ilike) %]</td>
+     <td>[% L.input_tag('filter.customer.customernumber:substr::ilike', filter.customer.customernumber_substr__ilike, class="rs_input_field") %]</td>
     </tr>
 
     <tr>
      <th align="right">[% LxERP.t8("Requirement Spec Type") %]</th>
-     <td>[% L.select_tag('filter.type_id', SELF.types, default=filter.type_id, title_key="description", with_empty=1) %]</td>
+     <td>[% L.select_tag('filter.type_id', SELF.types, default=filter.type_id, title_key="description", with_empty=1, class="rs_input_field") %]</td>
     </tr>
 
     <tr>
      <th align="right">[% LxERP.t8("Requirement Spec Status") %]</th>
-     <td>[% L.select_tag('filter.status_id[]', SELF.statuses, default=filter.status_id_, title_key="description", multiple=1) %][%# NOTE: the trailing '_' is NOT a mistake -- look at SL::Controller::Helper::Filtered for the explanation! %]</td>
+     <td>[% L.select_tag('filter.status_id[]', SELF.statuses, default=filter.status_id_, title_key="description", multiple=1, class="rs_input_field") %][%# NOTE: the trailing '_' is NOT a mistake -- look at SL::Controller::Helper::Filtered for the explanation! %]</td>
     </tr>
 
     <tr>
      <th align="right">[% LxERP.t8("Project Number") %]</th>
-     <td>[% L.input_tag('filter.project.projectnumber:substr::ilike', filter.project.projectnumber_substr__ilike) %]</td>
+     <td>[% L.input_tag('filter.project.projectnumber:substr::ilike', filter.project.projectnumber_substr__ilike, class="rs_input_field") %]</td>
     </tr>
     <tr>
      <th align="right">[% LxERP.t8("Project Description") %]</th>
-     <td>[% L.input_tag('filter.project.description:substr::ilike', filter.project.description_substr__ilike) %]</td>
+     <td>[% L.input_tag('filter.project.description:substr::ilike', filter.project.description_substr__ilike, class="rs_input_field") %]</td>
     </tr>
+
+    [% FOREACH cvar_cfg = SELF.cvar_configs %]
+     [%- IF cvar_cfg.searchable %]
+      <tr>
+       <th align="right">[% HTML.escape(cvar_cfg.description) %]</th>
+       <td>[% INCLUDE 'common/render_cvar_filter_input.html' cvar_cfg=cvar_cfg cvar_class="rs_input_field" %]</td>
+      </tr>
+     [% END %]
+    [% END %]
+
+    [% L.hidden_tag("include_cvars.dummy__", 1) %]
+    [% IF SELF.includeable_cvar_configs.size %]
+     <tr>
+      <th align="right">[% LxERP.t8("Include in Report") %]</th>
+      <td>
+       <table>
+        <tr>
+         [% FOREACH cvar_cfg = SELF.includeable_cvar_configs %]
+          <td>
+           [% name__ = cvar_cfg.name;
+              L.checkbox_tag("include_cvars." _ name__, value="1", checked=(SELF.include_cvars.$name__ ? 1 : ''), label=cvar_cfg.description) %]
+          </td>
+          [%- IF !loop.last && ((loop.count % 3) == 0) %]
+           </tr><tr>
+          [% END %]
+         [% END %]
+        </tr>
+       </table>
+      </td>
+     </tr>
+    [% END %]
 [%- END %]
    </table>
   </p>
index 20bf7a1..a89a99b 100644 (file)
@@ -1,4 +1,4 @@
-[%- USE LxERP -%][%- USE L -%]
+[%- USE LxERP -%][%- USE L -%][%- USE HTML -%]
 [%- DEFAULT id_prefix = 'basic_settings_form'
             submit_as = 'post'
 %]
 
 [%- END %]
 
+  [% cvars = SELF.requirement_spec.cvars_by_config %]
+
+  [% FOREACH var = cvars %]
+   <tr>
+    <td>[% HTML.escape(var.config.description) %]</td>
+
+    <td>
+      [% INCLUDE 'common/render_cvar_input.html'
+                 cvar_name_prefix = 'cvars.'
+      %]
+    </td>
+   </tr>
+  [% END %]
+
  </table>
 
 [%- IF SELF.copy_source %]
index 7f69f8c..af75628 100644 (file)
    <td>[% HTML.escape(SELF.requirement_spec.hourly_rate_as_number) %]</td>
   </tr>
 
+  [% cvars = SELF.requirement_spec.cvars_by_config %]
+
+  [% FOREACH var = cvars %]
+   <tr class="listrow">
+    <td>[% HTML.escape(var.config.description) %]</td>
+    <td>[% HTML.escape(var.value_as_text) %]</td>
+   </tr>
+  [% END %]
+
 [%- END %]
 
  </table>