Merge branch 'b-3.6.1' of ../kivitendo-erp_20220811
[kivitendo-erp.git] / SL / DB / RequirementSpec.pm
index 5cfb5dc..024e60d 100644 (file)
@@ -3,10 +3,17 @@ package SL::DB::RequirementSpec;
 use strict;
 
 use Carp;
+use List::Util qw(max reduce);
 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);
 
@@ -26,10 +33,32 @@ __PACKAGE__->meta->add_relationship(
     class          => 'SL::DB::RequirementSpec',
     column_map     => { id => 'working_copy_id' },
   },
+  versions         => {
+    type           => 'one to many',
+    class          => 'SL::DB::RequirementSpecVersion',
+    column_map     => { id => 'requirement_spec_id' },
+  },
+  working_copy_versions => {
+    type           => 'one to many',
+    class          => 'SL::DB::RequirementSpecVersion',
+    column_map     => { id => 'working_copy_id' },
+  },
+  orders           => {
+    type           => 'one to many',
+    class          => 'SL::DB::RequirementSpecOrder',
+    column_map     => { id => 'requirement_spec_id' },
+  },
+  parts            => {
+    type           => 'one to many',
+    class          => 'SL::DB::RequirementSpecPart',
+    column_map     => { id => 'requirement_spec_id' },
+  },
 );
 
 __PACKAGE__->meta->initialize;
 
+__PACKAGE__->attr_duration(qw(time_estimation));
+
 __PACKAGE__->before_save('_before_save_initialize_not_null_columns');
 
 sub validate {
@@ -44,12 +73,21 @@ sub validate {
 sub _before_save_initialize_not_null_columns {
   my ($self) = @_;
 
-  $self->previous_section_number(0) if !defined $self->previous_section_number;
-  $self->previous_fb_number(0)      if !defined $self->previous_fb_number;
+  for (qw(previous_section_number previous_fb_number previous_picture_number)) {
+    $self->$_(0) if !defined $self->$_;
+  }
 
   return 1;
 }
 
+sub version {
+  my ($self) = @_;
+
+  croak "Not a writer" if scalar(@_) > 1;
+
+  return $self->is_working_copy ? $self->working_copy_versions->[0] : $self->versions->[0];
+}
+
 sub text_blocks_sorted {
   my ($self, %params) = _hashify(1, @_);
 
@@ -57,7 +95,7 @@ sub text_blocks_sorted {
   @text_blocks    = grep { $_->output_position == $params{output_position} } @text_blocks if exists $params{output_position};
   @text_blocks    = sort { $a->position        <=> $b->position            } @text_blocks;
 
-  return wantarray ? @text_blocks : \@text_blocks;
+  return \@text_blocks;
 }
 
 sub sections_sorted {
@@ -65,12 +103,18 @@ sub sections_sorted {
 
   croak "This sub is not a writer" if @rest;
 
-  my @sections = sort { $a->position <=> $b->position } grep { !$_->parent_id } @{ $self->items };
-  return wantarray ? @sections : \@sections;
+  return [ sort { $a->position <=> $b->position } grep { !$_->parent_id } @{ $self->items } ];
 }
 
 sub sections { &sections_sorted; }
 
+sub orders_sorted {
+  my ($self, %params) = _hashify(1, @_);
+  my $by              = $params{by} || 'itime';
+
+  return [ sort { $a->$by cmp $b->$by } @{ $self->orders } ];
+}
+
 sub displayable_name {
   my ($self) = @_;
 
@@ -84,7 +128,15 @@ sub versioned_copies_sorted {
   @copies    = grep { $_->version->version_number <=  $params{max_version_number} } @copies if $params{max_version_number};
   @copies    = sort { $a->version->version_number <=> $b->version->version_number } @copies;
 
-  return wantarray ? @copies : \@copies;
+  return \@copies;
+}
+
+sub parts_sorted {
+  my ($self, @rest) = @_;
+
+  croak "This sub is not a writer" if @rest;
+
+  return [ sort { $a->position <=> $b->position } @{ $self->parts } ];
 }
 
 sub create_copy {
@@ -93,7 +145,7 @@ sub create_copy {
   return $self->_create_copy(%params) if $self->db->in_transaction;
 
   my $copy;
-  if (!$self->db->do_transaction(sub { $copy = $self->_create_copy(%params) })) {
+  if (!$self->db->with_transaction(sub { $copy = $self->_create_copy(%params) })) {
     $::lxdebug->message(LXDebug->WARN(), "create_copy failed: " . join("\n", (split(/\n/, $self->db->error))[0..2]));
     return undef;
   }
@@ -104,26 +156,63 @@ sub create_copy {
 sub _create_copy {
   my ($self, %params) = @_;
 
-  my $copy = Rose::DB::Object::Helpers::clone_and_reset($self);
+  my $copy = $self->clone_and_reset;
   $copy->copy_from($self, %params);
 
   return $copy;
 }
 
-sub copy_from {
-  my ($self, $source, %attributes) = @_;
+sub _copy_from {
+  my ($self, $params, %attributes) = @_;
+
+  my $source = $params->{source};
 
   croak "Missing parameter 'source'" unless $source;
 
   # Copy attributes.
-  $self->assign_attributes(map({ ($_ => $source->$_) } qw(type_id status_id customer_id project_id title hourly_rate net_sum previous_section_number previous_fb_number is_template)),
-                           %attributes);
+  if (!$params->{paste_template}) {
+    $self->assign_attributes(map({ ($_ => $source->$_) } qw(type_id status_id customer_id project_id title hourly_rate time_estimation previous_section_number previous_fb_number previous_picture_number is_template)),
+                             %attributes);
+  }
+
+  # Copy custom variables.
+  foreach my $var (@{ $source->cvars_by_config }) {
+    $self->cvar_by_name($var->config->name)->value($var->value);
+  }
 
-  # Clone text blocks.
-  $self->text_blocks(map { Rose::DB::Object::Helpers::clone_and_reset($_) } @{ $source->text_blocks });
+  my %paste_template_result;
+
+  # Clone text blocks and pictures.
+  my $clone_and_reset_position = sub {
+    my ($src_obj) = @_;
+    my $cloned    = $src_obj->clone_and_reset;
+    $cloned->position(undef);
+    return $cloned;
+  };
+
+  my $clone_text_block = sub {
+    my ($text_block) = @_;
+    my $cloned       = $text_block->clone_and_reset;
+    $cloned->position(undef);
+    $cloned->pictures([ map { $clone_and_reset_position->($_) } @{ $text_block->pictures_sorted } ]);
+    return $cloned;
+  };
+
+  $paste_template_result{text_blocks} = [ map { $clone_text_block->($_) } @{ $source->text_blocks_sorted } ];
+
+  if (!$params->{paste_template}) {
+    $self->text_blocks($paste_template_result{text_blocks});
+  } else {
+    $self->add_text_blocks($paste_template_result{text_blocks});
+  }
+
+  # Clone additional parts.
+  $paste_template_result{parts} = [ map { $clone_and_reset_position->($_) } @{ $source->parts } ];
+  my $accessor                  = $params->{paste_template} ? "add_parts" : "parts";
+  $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;
 
@@ -131,8 +220,10 @@ sub copy_from {
   my $clone_item;
   $clone_item = sub {
     my ($item) = @_;
-    my $cloned = Rose::DB::Object::Helpers::clone_and_reset($item);
+    my $cloned = $item->clone_and_reset;
     $cloned->requirement_spec_id($self->id);
+    $cloned->position(undef);
+    $cloned->fb_number(undef) if $params->{paste_template};
     $cloned->children(map { $clone_item->($_) } @{ $item->children });
 
     $id_to_clone{ $item->id } = $cloned;
@@ -140,7 +231,13 @@ sub copy_from {
     return $cloned;
   };
 
-  $self->items(map { $clone_item->($_) } @{ $source->sections });
+  $paste_template_result{sections} = [ map { $clone_item->($_) } @{ $source->sections_sorted } ];
+
+  if (!$params->{paste_template}) {
+    $self->items($paste_template_result{sections});
+  } else {
+    $self->add_items($paste_template_result{sections});
+  }
 
   # Save the items -- need to do that before setting dependencies.
   $self->save;
@@ -151,45 +248,27 @@ sub copy_from {
     $id_to_clone{ $item->id }->update_attributes(dependencies => [ map { $id_to_clone{$_->id} } @{ $item->dependencies } ]);
   }
 
-  $self->update_attributes(%attributes);
+  $self->update_attributes(%attributes) unless $params->{paste_template};
 
-  return $self;
+  return %paste_template_result;
 }
 
-sub delete_items {
-  my ($self) = @_;
+sub copy_from {
+  my ($self, $source, %attributes) = @_;
 
-  my $worker = sub {
-    # First convert all items to sections so that deleting won't
-    # violate foreign key constraints on parent_id.
-    SL::DB::Manager::RequirementSpecItem->update_all(
-      set   => { parent_id => undef, item_type => 'section' },
-      where => [
-        requirement_spec_id => $self->id,
-        '!parent_id'        => undef,
-      ]);
-
-    # Now delete all items in one go.
-    SL::DB::Manager::RequirementSpecItem->delete_all(where => [ requirement_spec_id => $self->id ]);
-
-    # Last clear values in ourself.
-    $self->items([]);
-  };
+  $self->db->with_transaction(sub { $self->_copy_from({ source => $source, paste_template => 0 }, %attributes); });
+}
 
-  return $self->db->in_transaction ? $worker->() : $self->db->do_transaction($worker);
+sub paste_template {
+  my ($self, $template) = @_;
+
+  $self->db->with_transaction(sub { $self->_copy_from({ source => $template, paste_template => 1 }); });
 }
 
-sub previous_version {
+sub highest_version {
   my ($self) = @_;
 
-  my $and    = $self->version_id ? " AND (version_id <> ?)" : "";
-  my $id     = $self->db->dbh->selectrow_array(<<SQL, undef, $self->id, ($self->version_id) x !!$self->version_id);
-   SELECT MAX(id)
-   FROM requirement_specs
-   WHERE (working_copy_id = ?) $and
-SQL
-
-  return $id ? SL::DB::RequirementSpec->new(id => $id)->load : undef;
+  return reduce { $a->version->version_number > $b->version->version_number ? $a : $b } @{ $self->versioned_copies };
 }
 
 sub is_working_copy {
@@ -200,14 +279,21 @@ sub is_working_copy {
 
 sub next_version_number {
   my ($self) = @_;
-  my $max_number = $self->db->dbh->selectrow_array(<<SQL, undef, $self->id);
-    SELECT COALESCE(MAX(ver.version_number), 0)
-    FROM requirement_spec_versions ver
-    JOIN requirement_specs rs ON (rs.version_id = ver.id)
-    WHERE rs.working_copy_id = ?
+
+  return 1 if !$self->id;
+
+  my ($max_number) = $self->db->dbh->selectrow_array(<<SQL, {}, $self->id, $self->id);
+    SELECT MAX(v.version_number)
+    FROM requirement_spec_versions v
+    WHERE v.requirement_spec_id IN (
+      SELECT rs.id
+      FROM requirement_specs rs
+      WHERE (rs.id              = ?)
+         OR (rs.working_copy_id = ?)
+    )
 SQL
 
-  return $max_number + 1;
+  return ($max_number // 0) + 1;
 }
 
 sub create_version {
@@ -216,13 +302,16 @@ sub create_version {
   croak "Cannot work on a versioned copy" if $self->working_copy_id;
 
   my ($copy, $version);
-  my $ok = $self->db->do_transaction(sub {
+  my $ok = $self->db->with_transaction(sub {
     delete $attributes{version_number};
 
-    $version = SL::DB::RequirementSpecVersion->new(%attributes, version_number => $self->next_version_number)->save;
-    $copy    = $self->create_copy;
-    $copy->update_attributes(version_id => $version->id, working_copy_id => $self->id);
-    $self->update_attributes(version_id => $version->id);
+    SL::DB::Manager::RequirementSpecVersion->update_all(
+      set   => [ working_copy_id     => undef     ],
+      where => [ requirement_spec_id => $self->id ],
+    );
+
+    $copy    = $self->create_copy(working_copy_id => $self->id);
+    $version = SL::DB::RequirementSpecVersion->new(%attributes, version_number => $self->next_version_number, requirement_spec_id => $copy->id, working_copy_id => $self->id)->save;
 
     1;
   });
@@ -235,8 +324,256 @@ sub invalidate_version {
 
   croak "Cannot work on a versioned copy" if $self->working_copy_id;
 
-  return if !$self->id || !$self->version_id;
-  $self->update_attributes(version_id => undef);
+  return if !$self->id;
+
+  SL::DB::Manager::RequirementSpecVersion->update_all(
+    set   => [ working_copy_id => undef     ],
+    where => [ working_copy_id => $self->id ],
+  );
+}
+
+sub compare_to {
+  my ($self, $other) = @_;
+
+  return $self->id <=> $other->id;
 }
 
 1;
+__END__
+
+=pod
+
+=encoding utf8
+
+=head1 NAME
+
+SL::DB::RequirementSpec - RDBO model for requirement specs
+
+=head1 OVERVIEW
+
+The database structure behind requirement specs is a bit involved. The
+important thing is how working copy/versions are handled.
+
+The table contains three important columns: C<id> (which is also the
+primary key) and C<working_copy_id>. C<working_copy_id> is a
+self-referencing column: it can be C<NULL>, but if it isn't then it
+contains another requirement spec C<id>.
+
+Versions are represented similarly. The C<requirement_spec_versions>
+table has three important columns: C<id> (the primary key),
+C<requirement_spec_id> (references C<requirement_specs.id> and must
+not be C<NULL>) and C<working_copy_id> (references
+C<requirement_specs.id> as well but can be
+C<NULL>). C<working_copy_id> points to the working copy if and only if
+the working copy is currently equal to a versioned copy.
+
+The design is as follows:
+
+=over 2
+
+=item * The user is always working on a working copy. The working copy
+is identified in the database by having C<working_copy_id> set to
+C<NULL>.
+
+=item * All other entries in this table are referred to as I<versioned
+copies>. A versioned copy is a copy of a working frozen at the moment
+in time it was created. Each versioned copy refers back to the working
+copy it belongs to: each has its C<working_copy_id> set.
+
+=item * Each versioned copy must be referenced from an entry in the
+table C<requirement_spec_versions> via
+C<requirement_spec_id>.
+
+=item * Directly after creating a versioned copy even the working copy
+itself is referenced from a version via that table's
+C<working_copy_id> column. However, any modification that will be
+visible to the customer (text, positioning etc but not internal things
+like time/cost estimation changes) will cause the version to be
+disassociated from the working copy. This is achieved via before save
+hooks in Perl.
+
+=back
+
+=head1 DATABASE TRIGGERS AND CHECKS
+
+Several database triggers and consistency checks exist that manage
+requirement specs, their items and their dependencies. These are
+described here instead of in the individual files for the other RDBO
+models.
+
+=head2 DELETION
+
+When you delete a requirement spec all of its dependencies (items,
+text blocks, versions etc.) are deleted by triggers.
+
+When you delete an item (either a section or a (sub-)function block)
+all of its children will be deleted as well. This will trigger the
+same trigger resulting in a recursive deletion with the bottom-most
+items being deleted first. Their item dependencies are deleted as
+well.
+
+=head2 UPDATING
+
+Whenever you update a requirement spec item a trigger will fire that
+will update the parent's C<time_estimation> column. This also happens
+when an item is deleted or updated.
+
+=head2 CONSISTENCY CHECKS
+
+Several consistency checks are applied to requirement spec items:
+
+=over 2
+
+=item * Column C<requirement_spec_item.item_type> can only contain one of
+the values C<section>, C<function-block> or C<sub-function-block>.
+
+=item * Column C<requirement_spec_item.parent_id> must be C<NULL> if
+C<requirement_spec_item.item_type> is set to C<section> and C<NOT
+NULL> otherwise.
+
+=back
+
+=head1 FUNCTIONS
+
+=over 4
+
+=item C<copy_from $source, %attributes>
+
+Copies everything (basic attributes like type/title/customer, items,
+text blocks, time/cost estimation) save for the versions from the
+other requirement spec object C<$source> into C<$self> and saves
+it. This is done within a transaction.
+
+C<%attributes> are attributes that are assigned to C<$self> after all
+the basic attributes from C<$source> have been assigned.
+
+This function can be used for resetting a working copy to a specific
+version. Example:
+
+  my $requirement_spec = SL::DB::RequirementSpec->new(id => $::form->{id})->load;
+  my $versioned_copy   = SL::DB::RequirementSpec->new(id => $::form->{versioned_copy_id})->load;
+
+  $requirement_spec->copy_from($versioned_copy);
+  $versioned_copy->version->update_attributes(working_copy_id => $requirement_spec->id);
+
+=item C<create_copy>
+
+Creates and returns a copy of C<$self>. The copy is already
+saved. Creating the copy happens within a transaction.
+
+=item C<create_version %attributes>
+
+Prerequisites: C<$self> must be a working copy (see the overview),
+not a versioned copy.
+
+This function creates a new version for C<$self>. This involves
+several steps:
+
+=over 2
+
+=item 1. The next version number is calculated using
+L</next_version_number>.
+
+=item 2. A copy of C<$self> is created with L</create_copy>.
+
+=item 3. An instance of L<SL::DB::RequirementSpecVersion> is
+created. Its attributes are copied from C<%attributes> save for the
+version number which is taken from step 1.
+
+=item 4. The version instance created in step 3 is referenced to the
+the copy from step 2 via C<requirement_spec_id> and to the working
+copy for which the version was created via C<working_copy_id>.
+
+=back
+
+All this is done within a transaction.
+
+In case of success a two-element list is returned consisting of the
+copy & version objects created in steps 3 and 2 respectively. In case
+of a failure an empty list will be returned.
+
+=item C<displayable_name>
+
+Returns a human-readable name for this instance consisting of the type
+and the title.
+
+=item C<highest_version>
+
+Given a working copy C<$self> this function returns the versioned copy
+of C<$self> with the highest version number. If such a version exist
+its instance is returned. Otherwise C<undef> is returned.
+
+This can be used for calculating the difference between the working
+copy and the last version created for it.
+
+=item C<invalidate_version>
+
+Prerequisites: C<$self> must be a working copy (see the overview),
+not a versioned copy.
+
+Sets any C<working_copy_id> field in the C<requirement_spec_versions>
+table containing C<$self-E<gt>id> to C<undef>.
+
+=item C<is_working_copy>
+
+Returns trueish if C<$self> is a working copy and not a versioned
+copy. The condition for this is that C<working_copy_id> is C<undef>.
+
+=item C<next_version_number>
+
+Calculates and returns the next version number for this requirement
+spec. Version numbers start at 1 and are incremented by one for each
+version created for it, no matter whether or not it has been reverted
+to a previous version since. It boils down to this pseudo-code:
+
+  if (has_never_had_a_version)
+    return 1
+  else
+    return max(version_number for all versions for this requirement spec) + 1
+
+=item C<sections>
+
+An alias for L</sections_sorted>.
+
+=item C<sections_sorted>
+
+Returns an array reference of requirement spec items that do not have
+a parent -- meaning that are sections.
+
+This is not a writer. Use the C<items> relationship for that.
+
+=item C<text_blocks_sorted %params>
+
+Returns an array reference of text blocks sorted by their positional
+column in ascending order. If the C<output_position> parameter is
+given then only the text blocks belonging to that C<output_position>
+are returned.
+
+=item C<parts_sorted>
+
+Returns an array reference of additional parts sorted by their
+positional column in ascending order.
+
+=item C<validate>
+
+Validate values before saving. Returns list or human-readable error
+messages (if any).
+
+=item C<versioned_copies_sorted %params>
+
+Returns an array reference of versioned copies sorted by their version
+number in ascending order. If the C<max_version_number> parameter is
+given then only the versioned copies whose version number is less than
+or equal to C<max_version_number> are returned.
+
+=back
+
+=head1 BUGS
+
+Nothing here yet.
+
+=head1 AUTHOR
+
+Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>
+
+=cut