Pflichtenhefte: Dokumentation; Refactoring; Bugfix Diff-Berechnung
[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::Locale::String;
12 use SL::Util qw(_hashify);
13
14 __PACKAGE__->meta->add_relationship(
15   items            => {
16     type           => 'one to many',
17     class          => 'SL::DB::RequirementSpecItem',
18     column_map     => { id => 'requirement_spec_id' },
19   },
20   text_blocks      => {
21     type           => 'one to many',
22     class          => 'SL::DB::RequirementSpecTextBlock',
23     column_map     => { id => 'requirement_spec_id' },
24   },
25   versioned_copies => {
26     type           => 'one to many',
27     class          => 'SL::DB::RequirementSpec',
28     column_map     => { id => 'working_copy_id' },
29   },
30 );
31
32 __PACKAGE__->meta->initialize;
33
34 __PACKAGE__->before_save('_before_save_initialize_not_null_columns');
35
36 sub validate {
37   my ($self) = @_;
38
39   my @errors;
40   push @errors, t8('The title is missing.') if !$self->title;
41
42   return @errors;
43 }
44
45 sub _before_save_initialize_not_null_columns {
46   my ($self) = @_;
47
48   $self->previous_section_number(0) if !defined $self->previous_section_number;
49   $self->previous_fb_number(0)      if !defined $self->previous_fb_number;
50
51   return 1;
52 }
53
54 sub text_blocks_sorted {
55   my ($self, %params) = _hashify(1, @_);
56
57   my @text_blocks = @{ $self->text_blocks };
58   @text_blocks    = grep { $_->output_position == $params{output_position} } @text_blocks if exists $params{output_position};
59   @text_blocks    = sort { $a->position        <=> $b->position            } @text_blocks;
60
61   return wantarray ? @text_blocks : \@text_blocks;
62 }
63
64 sub sections_sorted {
65   my ($self, @rest) = @_;
66
67   croak "This sub is not a writer" if @rest;
68
69   my @sections = sort { $a->position <=> $b->position } grep { !$_->parent_id } @{ $self->items };
70   return wantarray ? @sections : \@sections;
71 }
72
73 sub sections { &sections_sorted; }
74
75 sub displayable_name {
76   my ($self) = @_;
77
78   return sprintf('%s: "%s"', $self->type->description, $self->title);
79 }
80
81 sub versioned_copies_sorted {
82   my ($self, %params) = _hashify(1, @_);
83
84   my @copies = @{ $self->versioned_copies };
85   @copies    = grep { $_->version->version_number <=  $params{max_version_number} } @copies if $params{max_version_number};
86   @copies    = sort { $a->version->version_number <=> $b->version->version_number } @copies;
87
88   return wantarray ? @copies : \@copies;
89 }
90
91 sub create_copy {
92   my ($self, %params) = @_;
93
94   return $self->_create_copy(%params) if $self->db->in_transaction;
95
96   my $copy;
97   if (!$self->db->do_transaction(sub { $copy = $self->_create_copy(%params) })) {
98     $::lxdebug->message(LXDebug->WARN(), "create_copy failed: " . join("\n", (split(/\n/, $self->db->error))[0..2]));
99     return undef;
100   }
101
102   return $copy;
103 }
104
105 sub _create_copy {
106   my ($self, %params) = @_;
107
108   my $copy = Rose::DB::Object::Helpers::clone_and_reset($self);
109   $copy->copy_from($self, %params);
110
111   return $copy;
112 }
113
114 sub _copy_from {
115   my ($self, $source, %attributes) = @_;
116
117   croak "Missing parameter 'source'" unless $source;
118
119   # Copy attributes.
120   $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)),
121                            %attributes);
122
123   # Clone text blocks.
124   $self->text_blocks(map { Rose::DB::Object::Helpers::clone_and_reset($_) } @{ $source->text_blocks });
125
126   # Save new object -- we need its ID for the items.
127   $self->save;
128
129   my %id_to_clone;
130
131   # Clone items.
132   my $clone_item;
133   $clone_item = sub {
134     my ($item) = @_;
135     my $cloned = Rose::DB::Object::Helpers::clone_and_reset($item);
136     $cloned->requirement_spec_id($self->id);
137     $cloned->children(map { $clone_item->($_) } @{ $item->children });
138
139     $id_to_clone{ $item->id } = $cloned;
140
141     return $cloned;
142   };
143
144   $self->items(map { $clone_item->($_) } @{ $source->sections });
145
146   # Save the items -- need to do that before setting dependencies.
147   $self->save;
148
149   # Set dependencies.
150   foreach my $item (@{ $source->items }) {
151     next unless @{ $item->dependencies };
152     $id_to_clone{ $item->id }->update_attributes(dependencies => [ map { $id_to_clone{$_->id} } @{ $item->dependencies } ]);
153   }
154
155   $self->update_attributes(%attributes);
156
157   return $self;
158 }
159
160 sub copy_from {
161   my ($self, $source, %attributes) = @_;
162
163   $self->db->with_transaction(sub { $self->_copy_from($source, %attributes); });
164 }
165
166 sub highest_version {
167   my ($self) = @_;
168
169   return reduce { $a->version->version_number > $b->version->version_number ? $a : $b } @{ $self->versioned_copies };
170 }
171
172 sub is_working_copy {
173   my ($self) = @_;
174
175   return !$self->working_copy_id;
176 }
177
178 sub next_version_number {
179   my ($self) = @_;
180
181   return max(0, map { $_->version->version_number } @{ $self->versioned_copies }) + 1;
182 }
183
184 sub create_version {
185   my ($self, %attributes) = @_;
186
187   croak "Cannot work on a versioned copy" if $self->working_copy_id;
188
189   my ($copy, $version);
190   my $ok = $self->db->with_transaction(sub {
191     delete $attributes{version_number};
192
193     $version = SL::DB::RequirementSpecVersion->new(%attributes, version_number => $self->next_version_number)->save;
194     $copy    = $self->create_copy;
195     $copy->update_attributes(version_id => $version->id, working_copy_id => $self->id);
196     $self->update_attributes(version_id => $version->id);
197
198     1;
199   });
200
201   return $ok ? ($copy, $version) : ();
202 }
203
204 sub invalidate_version {
205   my ($self, %params) = @_;
206
207   croak "Cannot work on a versioned copy" if $self->working_copy_id;
208
209   return if !$self->id || !$self->version_id;
210   $self->update_attributes(version_id => undef);
211 }
212
213 1;
214 __END__
215
216 =pod
217
218 =encoding utf8
219
220 =head1 NAME
221
222 SL::DB::RequirementSpec - RDBO model for requirement specs
223
224 =head1 OVERVIEW
225
226 The database structure behind requirement specs is a bit involved. The
227 important thing is how working copy/versions are handled.
228
229 The table contains three important columns: C<id> (which is also the
230 primary key), C<working_copy_id> and C<version_id>. C<working_copy_id>
231 is a self-referencing column: it can be C<NULL>, but if it isn't then
232 it contains another requirement spec C<id>. C<version_id> on the other
233 hand references the table C<requirement_spec_versions>.
234
235 The design is as follows:
236
237 =over 2
238
239 =item * The user is always working on a working copy. The working copy
240 is identified in the database by having C<working_copy_id> set to
241 C<NULL>.
242
243 =item * All other entries in this table are referred to as I<versioned
244 copies>. A versioned copy is a copy of a working frozen at the moment
245 in time it was created. Each versioned copy refers back to the working
246 copy it belongs to: each has its C<working_copy_id> set.
247
248 =item * Each versioned copy must reference an entry in the table
249 C<requirement_spec_versions>. Meaning: for each versioned copy
250 C<version_id> must not be C<NULL>.
251
252 =item * Directly after creating a versioned copy even the working copy
253 itself points to a certain version via its C<version_id> column: to
254 the same version that the versioned copy just created points
255 to. However, any modification that will be visible to the customer
256 (text, positioning etc but not internal things like time/cost
257 estimation changes) will cause the working copy to be set to 'no
258 version' again. This is achieved via before save hooks in Perl.
259
260 =back
261
262 =head1 DATABASE TRIGGERS AND CHECKS
263
264 Several database triggers and consistency checks exist that manage
265 requirement specs, their items and their dependencies. These are
266 described here instead of in the individual files for the other RDBO
267 models.
268
269 =head2 DELETION
270
271 When you delete a requirement spec all of its dependencies (items,
272 text blocks, versions etc.) are deleted by triggers.
273
274 When you delete an item (either a section or a (sub-)function block)
275 all of its children will be deleted as well. This will trigger the
276 same trigger resulting in a recursive deletion with the bottom-most
277 items being deleted first. Their item dependencies are deleted as
278 well.
279
280 =head2 UPDATING
281
282 Whenever you update a requirement spec item a trigger will fire that
283 will update the parent's C<time_estimation> column. This also happens
284 when an item is deleted or updated.
285
286 =head2 CONSISTENCY CHECKS
287
288 Several consistency checks are applied to requirement spec items:
289
290 =over 2
291
292 =item * Column C<requirement_spec_item.item_type> can only contain one of
293 the values C<section>, C<function-block> or C<sub-function-block>.
294
295 =item * Column C<requirement_spec_item.parent_id> must be C<NULL> if
296 C<requirement_spec_item.item_type> is set to C<section> and C<NOT
297 NULL> otherwise.
298
299 =back
300
301 =head1 FUNCTIONS
302
303 =over 4
304
305 =item C<copy_from $source, %attributes>
306
307 Copies everything (basic attributes like type/title/customer, items,
308 text blocks, time/cost estimation) save for the versions from the
309 other requirement spec object C<$source> into C<$self> and saves
310 it. This is done within a transaction.
311
312 C<%attributes> are attributes that are assigned to C<$self> after all
313 the basic attributes from C<$source> have been assigned.
314
315 This function can be used for resetting a working copy to a specific
316 version. Example:
317
318  my $requirement_spec = SL::DB::RequirementSpec->new(id => $::form->{id})->load;
319  my $versioned_copy   = SL::DB::RequirementSpec->new(id => $::form->{versioned_copy_id})->load;
320
321   $requirement_spec->copy_from(
322     $versioned_copy,
323     version_id => $versioned_copy->version_id,
324   );
325
326 =item C<create_copy>
327
328 Creates and returns a copy of C<$self>. The copy is already
329 saved. Creating the copy happens within a transaction.
330
331 =item C<create_version %attributes>
332
333 Prerequisites: C<$self> must be a working copy (see L</working_copy>),
334 not a versioned copy.
335
336 This function creates a new version for C<$self>. This involves
337 several steps:
338
339 =over 2
340
341 =item 1. The next version number is calculated using
342 L</next_version_number>.
343
344 =item 2. An instance of L<SL::DB::RequirementSpecVersion> is
345 created. Its attributes are copied from C<%attributes> save for the
346 version number which is taken from step 1.
347
348 =item 3. A copy of C<$self> is created with L</create_copy>.
349
350 =item 4. The version instance created in step is assigned to the copy
351 from step 3.
352
353 =item 5. The C<version_id> in C<$self> is set to the copy's ID from
354 step 3.
355
356 =back
357
358 All this is done within a transaction.
359
360 In case of success a two-element list is returned consisting of the
361 copy & version objects created in steps 3 and 2 respectively. In case
362 of a failure an empty list will be returned.
363
364 =item C<displayable_name>
365
366 Returns a human-readable name for this instance consisting of the type
367 and the title.
368
369 =item C<highest_version>
370
371 Given a working copy C<$self> this function returns the versioned copy
372 of C<$self> with the highest version number. If such a version exist
373 its instance is returned. Otherwise C<undef> is returned.
374
375 This can be used for calculating the difference between the working
376 copy and the last version created for it.
377
378 =item C<invalidate_version>
379
380 Prerequisites: C<$self> must be a working copy (see L</working_copy>),
381 not a versioned copy.
382
383 Sets the C<version_id> field to C<undef> and saves C<$self>.
384
385 =item C<is_working_copy>
386
387 Returns trueish if C<$self> is a working copy and not a versioned
388 copy. The condition for this is that C<working_copy_id> is C<undef>.
389
390 =item C<next_version_number>
391
392 Calculates and returns the next version number for this requirement
393 spec. Version numbers start at 1 and are incremented by one for each
394 version created for it, no matter whether or not it has been reverted
395 to a previous version since. It boils down to this pseudo-code:
396
397   if (has_never_had_a_version)
398     return 1
399   else
400     return max(version_number for all versions for this requirement spec) + 1
401
402 =item C<sections>
403
404 An alias for L</sections_sorted>.
405
406 =item C<sections_sorted>
407
408 Returns a list of requirement spec items that
409
410 This is not a writer. Use the C<items> relationship for that.
411
412 =item C<text_blocks_sorted %params>
413
414 Returns an array (or an array reference depending on context) of text
415 blocks sorted by their positional column in ascending order. If the
416 C<output_position> parameter is given then only the text blocks
417 belonging to that C<output_position> are returned.
418
419 =item C<validate>
420
421 Validate values before saving. Returns list or human-readable error
422 messages (if any).
423
424 =item C<versioned_copies_sorted %params>
425
426 Returns an array (or an array reference depending on context) of
427 versioned copies sorted by their version number in ascending order. If
428 the C<max_version_number> parameter is given then only the versioned
429 copies whose version number is less than or equal to
430 C<max_version_number> are returned.
431
432 =back
433
434 =head1 BUGS
435
436 Nothing here yet.
437
438 =head1 AUTHOR
439
440 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>
441
442 =cut