Pflichtenheft: Zugriff auf nicht vorhandenes »visible_item« verhindern
[kivitendo-erp.git] / SL / DB / RequirementSpec.pm
1 package SL::DB::RequirementSpec;
2
3 use strict;
4
5 use Carp;
6 use List::Util qw(max reduce);
7 use Rose::DB::Object::Helpers;
8
9 use SL::DB::MetaSetup::RequirementSpec;
10 use SL::DB::Manager::RequirementSpec;
11 use SL::DB::Helper::AttrDuration;
12 use SL::DB::Helper::CustomVariables (
13   module      => 'RequirementSpecs',
14   cvars_alias => 1,
15 );
16 use SL::DB::Helper::LinkedRecords;
17 use SL::Locale::String;
18 use SL::Util qw(_hashify);
19
20 __PACKAGE__->meta->add_relationship(
21   items            => {
22     type           => 'one to many',
23     class          => 'SL::DB::RequirementSpecItem',
24     column_map     => { id => 'requirement_spec_id' },
25   },
26   text_blocks      => {
27     type           => 'one to many',
28     class          => 'SL::DB::RequirementSpecTextBlock',
29     column_map     => { id => 'requirement_spec_id' },
30   },
31   versioned_copies => {
32     type           => 'one to many',
33     class          => 'SL::DB::RequirementSpec',
34     column_map     => { id => 'working_copy_id' },
35   },
36   versions         => {
37     type           => 'one to many',
38     class          => 'SL::DB::RequirementSpecVersion',
39     column_map     => { id => 'requirement_spec_id' },
40   },
41   working_copy_versions => {
42     type           => 'one to many',
43     class          => 'SL::DB::RequirementSpecVersion',
44     column_map     => { id => 'working_copy_id' },
45   },
46   orders           => {
47     type           => 'one to many',
48     class          => 'SL::DB::RequirementSpecOrder',
49     column_map     => { id => 'requirement_spec_id' },
50   },
51   parts            => {
52     type           => 'one to many',
53     class          => 'SL::DB::RequirementSpecPart',
54     column_map     => { id => 'requirement_spec_id' },
55   },
56 );
57
58 __PACKAGE__->meta->initialize;
59
60 __PACKAGE__->attr_duration(qw(time_estimation));
61
62 __PACKAGE__->before_save('_before_save_initialize_not_null_columns');
63
64 sub validate {
65   my ($self) = @_;
66
67   my @errors;
68   push @errors, t8('The title is missing.') if !$self->title;
69
70   return @errors;
71 }
72
73 sub _before_save_initialize_not_null_columns {
74   my ($self) = @_;
75
76   for (qw(previous_section_number previous_fb_number previous_picture_number)) {
77     $self->$_(0) if !defined $self->$_;
78   }
79
80   return 1;
81 }
82
83 sub version {
84   my ($self) = @_;
85
86   croak "Not a writer" if scalar(@_) > 1;
87
88   return $self->is_working_copy ? $self->working_copy_versions->[0] : $self->versions->[0];
89 }
90
91 sub text_blocks_sorted {
92   my ($self, %params) = _hashify(1, @_);
93
94   my @text_blocks = @{ $self->text_blocks };
95   @text_blocks    = grep { $_->output_position == $params{output_position} } @text_blocks if exists $params{output_position};
96   @text_blocks    = sort { $a->position        <=> $b->position            } @text_blocks;
97
98   return \@text_blocks;
99 }
100
101 sub sections_sorted {
102   my ($self, @rest) = @_;
103
104   croak "This sub is not a writer" if @rest;
105
106   return [ sort { $a->position <=> $b->position } grep { !$_->parent_id } @{ $self->items } ];
107 }
108
109 sub sections { &sections_sorted; }
110
111 sub orders_sorted {
112   my ($self, %params) = _hashify(1, @_);
113   my $by              = $params{by} || 'itime';
114
115   return [ sort { $a->$by cmp $b->$by } @{ $self->orders } ];
116 }
117
118 sub displayable_name {
119   my ($self) = @_;
120
121   return sprintf('%s: "%s"', $self->type->description, $self->title);
122 }
123
124 sub versioned_copies_sorted {
125   my ($self, %params) = _hashify(1, @_);
126
127   my @copies = @{ $self->versioned_copies };
128   @copies    = grep { $_->version->version_number <=  $params{max_version_number} } @copies if $params{max_version_number};
129   @copies    = sort { $a->version->version_number <=> $b->version->version_number } @copies;
130
131   return \@copies;
132 }
133
134 sub parts_sorted {
135   my ($self, @rest) = @_;
136
137   croak "This sub is not a writer" if @rest;
138
139   return [ sort { $a->position <=> $b->position } @{ $self->parts } ];
140 }
141
142 sub create_copy {
143   my ($self, %params) = @_;
144
145   return $self->_create_copy(%params) if $self->db->in_transaction;
146
147   my $copy;
148   if (!$self->db->do_transaction(sub { $copy = $self->_create_copy(%params) })) {
149     $::lxdebug->message(LXDebug->WARN(), "create_copy failed: " . join("\n", (split(/\n/, $self->db->error))[0..2]));
150     return undef;
151   }
152
153   return $copy;
154 }
155
156 sub _create_copy {
157   my ($self, %params) = @_;
158
159   my $copy = $self->clone_and_reset;
160   $copy->copy_from($self, %params);
161
162   return $copy;
163 }
164
165 sub _copy_from {
166   my ($self, $params, %attributes) = @_;
167
168   my $source = $params->{source};
169
170   croak "Missing parameter 'source'" unless $source;
171
172   # Copy attributes.
173   if (!$params->{paste_template}) {
174     $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)),
175                              %attributes);
176   }
177
178   # Copy custom variables.
179   foreach my $var (@{ $source->cvars_by_config }) {
180     $self->cvar_by_name($var->config->name)->value($var->value);
181   }
182
183   my %paste_template_result;
184
185   # Clone text blocks and pictures.
186   my $clone_and_reset_position = sub {
187     my ($src_obj) = @_;
188     my $cloned    = $src_obj->clone_and_reset;
189     $cloned->position(undef);
190     return $cloned;
191   };
192
193   my $clone_text_block = sub {
194     my ($text_block) = @_;
195     my $cloned       = $text_block->clone_and_reset;
196     $cloned->position(undef);
197     $cloned->pictures([ map { $clone_and_reset_position->($_) } @{ $text_block->pictures_sorted } ]);
198     return $cloned;
199   };
200
201   $paste_template_result{text_blocks} = [ map { $clone_text_block->($_) } @{ $source->text_blocks_sorted } ];
202
203   if (!$params->{paste_template}) {
204     $self->text_blocks($paste_template_result{text_blocks});
205   } else {
206     $self->add_text_blocks($paste_template_result{text_blocks});
207   }
208
209   # Clone additional parts.
210   $paste_template_result{parts} = [ map { $clone_and_reset_position->($_) } @{ $source->parts } ];
211   my $accessor                  = $params->{paste_template} ? "add_parts" : "parts";
212   $self->$accessor($paste_template_result{parts});
213
214   # Save new object -- we need its ID for the items.
215   $self->save(cascade => 1);
216
217   my %id_to_clone;
218
219   # Clone items.
220   my $clone_item;
221   $clone_item = sub {
222     my ($item) = @_;
223     my $cloned = $item->clone_and_reset;
224     $cloned->requirement_spec_id($self->id);
225     $cloned->position(undef);
226     $cloned->fb_number(undef) if $params->{paste_template};
227     $cloned->children(map { $clone_item->($_) } @{ $item->children });
228
229     $id_to_clone{ $item->id } = $cloned;
230
231     return $cloned;
232   };
233
234   $paste_template_result{sections} = [ map { $clone_item->($_) } @{ $source->sections_sorted } ];
235
236   if (!$params->{paste_template}) {
237     $self->items($paste_template_result{sections});
238   } else {
239     $self->add_items($paste_template_result{sections});
240   }
241
242   # Save the items -- need to do that before setting dependencies.
243   $self->save;
244
245   # Set dependencies.
246   foreach my $item (@{ $source->items }) {
247     next unless @{ $item->dependencies };
248     $id_to_clone{ $item->id }->update_attributes(dependencies => [ map { $id_to_clone{$_->id} } @{ $item->dependencies } ]);
249   }
250
251   $self->update_attributes(%attributes) unless $params->{paste_template};
252
253   return %paste_template_result;
254 }
255
256 sub copy_from {
257   my ($self, $source, %attributes) = @_;
258
259   $self->db->with_transaction(sub { $self->_copy_from({ source => $source, paste_template => 0 }, %attributes); });
260 }
261
262 sub paste_template {
263   my ($self, $template) = @_;
264
265   $self->db->with_transaction(sub { $self->_copy_from({ source => $template, paste_template => 1 }); });
266 }
267
268 sub highest_version {
269   my ($self) = @_;
270
271   return reduce { $a->version->version_number > $b->version->version_number ? $a : $b } @{ $self->versioned_copies };
272 }
273
274 sub is_working_copy {
275   my ($self) = @_;
276
277   return !$self->working_copy_id;
278 }
279
280 sub next_version_number {
281   my ($self) = @_;
282
283   return 1 if !$self->id;
284
285   my ($max_number) = $self->db->dbh->selectrow_array(<<SQL, {}, $self->id, $self->id);
286     SELECT MAX(v.version_number)
287     FROM requirement_spec_versions v
288     WHERE v.requirement_spec_id IN (
289       SELECT rs.id
290       FROM requirement_specs rs
291       WHERE (rs.id              = ?)
292          OR (rs.working_copy_id = ?)
293     )
294 SQL
295
296   return ($max_number // 0) + 1;
297 }
298
299 sub create_version {
300   my ($self, %attributes) = @_;
301
302   croak "Cannot work on a versioned copy" if $self->working_copy_id;
303
304   my ($copy, $version);
305   my $ok = $self->db->with_transaction(sub {
306     delete $attributes{version_number};
307
308     SL::DB::Manager::RequirementSpecVersion->update_all(
309       set   => [ working_copy_id     => undef     ],
310       where => [ requirement_spec_id => $self->id ],
311     );
312
313     $copy    = $self->create_copy(working_copy_id => $self->id);
314     $version = SL::DB::RequirementSpecVersion->new(%attributes, version_number => $self->next_version_number, requirement_spec_id => $copy->id, working_copy_id => $self->id)->save;
315
316     1;
317   });
318
319   return $ok ? ($copy, $version) : ();
320 }
321
322 sub invalidate_version {
323   my ($self, %params) = @_;
324
325   croak "Cannot work on a versioned copy" if $self->working_copy_id;
326
327   return if !$self->id;
328
329   SL::DB::Manager::RequirementSpecVersion->update_all(
330     set   => [ working_copy_id => undef     ],
331     where => [ working_copy_id => $self->id ],
332   );
333 }
334
335 sub compare_to {
336   my ($self, $other) = @_;
337
338   return $self->id <=> $other->id;
339 }
340
341 1;
342 __END__
343
344 =pod
345
346 =encoding utf8
347
348 =head1 NAME
349
350 SL::DB::RequirementSpec - RDBO model for requirement specs
351
352 =head1 OVERVIEW
353
354 The database structure behind requirement specs is a bit involved. The
355 important thing is how working copy/versions are handled.
356
357 The table contains three important columns: C<id> (which is also the
358 primary key) and C<working_copy_id>. C<working_copy_id> is a
359 self-referencing column: it can be C<NULL>, but if it isn't then it
360 contains another requirement spec C<id>.
361
362 Versions are represented similarly. The C<requirement_spec_versions>
363 table has three important columns: C<id> (the primary key),
364 C<requirement_spec_id> (references C<requirement_specs.id> and must
365 not be C<NULL>) and C<working_copy_id> (references
366 C<requirement_specs.id> as well but can be
367 C<NULL>). C<working_copy_id> points to the working copy if and only if
368 the working copy is currently equal to a versioned copy.
369
370 The design is as follows:
371
372 =over 2
373
374 =item * The user is always working on a working copy. The working copy
375 is identified in the database by having C<working_copy_id> set to
376 C<NULL>.
377
378 =item * All other entries in this table are referred to as I<versioned
379 copies>. A versioned copy is a copy of a working frozen at the moment
380 in time it was created. Each versioned copy refers back to the working
381 copy it belongs to: each has its C<working_copy_id> set.
382
383 =item * Each versioned copy must be referenced from an entry in the
384 table C<requirement_spec_versions> via
385 C<requirement_spec_id>.
386
387 =item * Directly after creating a versioned copy even the working copy
388 itself is referenced from a version via that table's
389 C<working_copy_id> column. However, any modification that will be
390 visible to the customer (text, positioning etc but not internal things
391 like time/cost estimation changes) will cause the version to be
392 disassociated from the working copy. This is achieved via before save
393 hooks in Perl.
394
395 =back
396
397 =head1 DATABASE TRIGGERS AND CHECKS
398
399 Several database triggers and consistency checks exist that manage
400 requirement specs, their items and their dependencies. These are
401 described here instead of in the individual files for the other RDBO
402 models.
403
404 =head2 DELETION
405
406 When you delete a requirement spec all of its dependencies (items,
407 text blocks, versions etc.) are deleted by triggers.
408
409 When you delete an item (either a section or a (sub-)function block)
410 all of its children will be deleted as well. This will trigger the
411 same trigger resulting in a recursive deletion with the bottom-most
412 items being deleted first. Their item dependencies are deleted as
413 well.
414
415 =head2 UPDATING
416
417 Whenever you update a requirement spec item a trigger will fire that
418 will update the parent's C<time_estimation> column. This also happens
419 when an item is deleted or updated.
420
421 =head2 CONSISTENCY CHECKS
422
423 Several consistency checks are applied to requirement spec items:
424
425 =over 2
426
427 =item * Column C<requirement_spec_item.item_type> can only contain one of
428 the values C<section>, C<function-block> or C<sub-function-block>.
429
430 =item * Column C<requirement_spec_item.parent_id> must be C<NULL> if
431 C<requirement_spec_item.item_type> is set to C<section> and C<NOT
432 NULL> otherwise.
433
434 =back
435
436 =head1 FUNCTIONS
437
438 =over 4
439
440 =item C<copy_from $source, %attributes>
441
442 Copies everything (basic attributes like type/title/customer, items,
443 text blocks, time/cost estimation) save for the versions from the
444 other requirement spec object C<$source> into C<$self> and saves
445 it. This is done within a transaction.
446
447 C<%attributes> are attributes that are assigned to C<$self> after all
448 the basic attributes from C<$source> have been assigned.
449
450 This function can be used for resetting a working copy to a specific
451 version. Example:
452
453   my $requirement_spec = SL::DB::RequirementSpec->new(id => $::form->{id})->load;
454   my $versioned_copy   = SL::DB::RequirementSpec->new(id => $::form->{versioned_copy_id})->load;
455
456   $requirement_spec->copy_from($versioned_copy);
457   $versioned_copy->version->update_attributes(working_copy_id => $requirement_spec->id);
458
459 =item C<create_copy>
460
461 Creates and returns a copy of C<$self>. The copy is already
462 saved. Creating the copy happens within a transaction.
463
464 =item C<create_version %attributes>
465
466 Prerequisites: C<$self> must be a working copy (see the overview),
467 not a versioned copy.
468
469 This function creates a new version for C<$self>. This involves
470 several steps:
471
472 =over 2
473
474 =item 1. The next version number is calculated using
475 L</next_version_number>.
476
477 =item 2. A copy of C<$self> is created with L</create_copy>.
478
479 =item 3. An instance of L<SL::DB::RequirementSpecVersion> is
480 created. Its attributes are copied from C<%attributes> save for the
481 version number which is taken from step 1.
482
483 =item 4. The version instance created in step 3 is referenced to the
484 the copy from step 2 via C<requirement_spec_id> and to the working
485 copy for which the version was created via C<working_copy_id>.
486
487 =back
488
489 All this is done within a transaction.
490
491 In case of success a two-element list is returned consisting of the
492 copy & version objects created in steps 3 and 2 respectively. In case
493 of a failure an empty list will be returned.
494
495 =item C<displayable_name>
496
497 Returns a human-readable name for this instance consisting of the type
498 and the title.
499
500 =item C<highest_version>
501
502 Given a working copy C<$self> this function returns the versioned copy
503 of C<$self> with the highest version number. If such a version exist
504 its instance is returned. Otherwise C<undef> is returned.
505
506 This can be used for calculating the difference between the working
507 copy and the last version created for it.
508
509 =item C<invalidate_version>
510
511 Prerequisites: C<$self> must be a working copy (see the overview),
512 not a versioned copy.
513
514 Sets any C<working_copy_id> field in the C<requirement_spec_versions>
515 table containing C<$self-E<gt>id> to C<undef>.
516
517 =item C<is_working_copy>
518
519 Returns trueish if C<$self> is a working copy and not a versioned
520 copy. The condition for this is that C<working_copy_id> is C<undef>.
521
522 =item C<next_version_number>
523
524 Calculates and returns the next version number for this requirement
525 spec. Version numbers start at 1 and are incremented by one for each
526 version created for it, no matter whether or not it has been reverted
527 to a previous version since. It boils down to this pseudo-code:
528
529   if (has_never_had_a_version)
530     return 1
531   else
532     return max(version_number for all versions for this requirement spec) + 1
533
534 =item C<sections>
535
536 An alias for L</sections_sorted>.
537
538 =item C<sections_sorted>
539
540 Returns an array reference of requirement spec items that do not have
541 a parent -- meaning that are sections.
542
543 This is not a writer. Use the C<items> relationship for that.
544
545 =item C<text_blocks_sorted %params>
546
547 Returns an array reference of text blocks sorted by their positional
548 column in ascending order. If the C<output_position> parameter is
549 given then only the text blocks belonging to that C<output_position>
550 are returned.
551
552 =item C<parts_sorted>
553
554 Returns an array reference of additional parts sorted by their
555 positional column in ascending order.
556
557 =item C<validate>
558
559 Validate values before saving. Returns list or human-readable error
560 messages (if any).
561
562 =item C<versioned_copies_sorted %params>
563
564 Returns an array reference of versioned copies sorted by their version
565 number in ascending order. If the C<max_version_number> parameter is
566 given then only the versioned copies whose version number is less than
567 or equal to C<max_version_number> are returned.
568
569 =back
570
571 =head1 BUGS
572
573 Nothing here yet.
574
575 =head1 AUTHOR
576
577 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>
578
579 =cut