]> wagnertech.de Git - mfinanz.git/commitdiff
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 84c55ee00555ee8622e083762b8f2b33a83aee78..ba9ce831dfe3be5432b0c9ccf501cb3ee5096533 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 ed7e844c19315b650585ef22648311a7c425ba48..456fe7176141c6a42043100cad45443af7b3678e 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 491eb63b58b9a6682b13b6b2f9280c892ed5f712..c38974e40c3680b56226d31ef0bfc7c4aa37d93c 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 3158cd09ac1931d0273f2922094e8b65d18fc73e..1e3618112dd7351505868cd21a614723e63d2b18 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 5404ebff8649fd3a00cf3384c6a1c2f54b5f0cef..ab7f83b33700235dd009a3e6bf47156949277069 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 713685519fa50962b319e8a0a582c98ffd6ab225..97837a287c8e1e4a844b724cf99f97133fce4ab1 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 35329494b12166ee4e65303e20ad4358a4bde788..5d18d8e89c1aa1fe8f5bdba8e558f44f70dd2ec9 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 fecd9d35860254df1b0c66cc9590c05ab79ac9bd..fa35de0b877392359c2b78842f1520c3ffa755f7 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 bf9e89c277dcd15512085c112af2b0f2ceeabc6b..accab2995a10acea07f630159c9ddaf5033a6ff6 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 bc68c17c4b18e7736953b7dec46b1283455325f9..1cc17712f7011b6041b7c8769ccb4e48a2ed35b9 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 1acd6a0d35ffd4c3dcf5d65be147f0a67f95bfc1..b2b6979d49770a12163fc450bfd32318ff691479 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 9dbd0d693977d25fe13ea8b899bd60f31be9f25f..36dfeb5a34b31ffb10f80b04b9cfae6b7cadf5d9 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 ffd4375a457edaf28dd30e26417e92c90c4c12d6..7128dd08e5f05a65d59074418ad88fcbb170b762 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 51db82f11f3c81bffb4018f8aeb7a8279b6bee26..eb1d81b676c1c37316c0afb6c7c2a4d51487fe93 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 583b26cd397cb66dcb40e08d75f57ea934a85820..a53c8c46929f4cebbc9e83de493b4b759ac37d04 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 f081ca261e245b96a7ae24bc841bf4c9b95fdfe3..14f6d10b1f3d0ac3284d6599b302ea4f2f3ae312 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 19c03dae08fe7a379118f16f48e0b6abcfc2d214..201709ff94776c76c7fccaad5155d0454b2573f0 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 20bf7a1164eb47636db39706718595546b4b86f4..a89a99b2895ce7ac6d2de7ea7d351b475033b4aa 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 7f69f8c576f39428f21198e4f62552d467c6d614..af756288cbdeb7c12acbc2f2afe79228b80318b3 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>