Projekt

Allgemein

Profil

« Zurück | Weiter » 

Revision 9687d2ce

Von Sven Schöling vor mehr als 3 Jahren hinzugefügt

  • ID 9687d2ce94190d858260e10dea0a882b77d3a9b6
  • Vorgänger c0b95291
  • Nachfolger 7e253668

Inventory Helper

Unterschiede anzeigen:

SL/Helper/Inventory.pm
1
package SL::Helper::Inventory;
2

  
3
use strict;
4
use Carp;
5
use DateTime;
6
use Exporter qw(import);
7
use List::Util qw(min);
8
use List::UtilsBy qw(sort_by);
9
use List::MoreUtils qw(any);
10

  
11
use SL::Locale::String qw(t8);
12
use SL::MoreCommon qw(listify);
13
use SL::DBUtils qw(selectall_hashref_query selectrow_query);
14
use SL::DB::TransferType;
15
use SL::X;
16

  
17
our @EXPORT_OK = qw(get_stock get_onhand allocate allocate_for_assembly produce_assembly);
18
our %EXPORT_TAGS = (ALL => \@EXPORT_OK);
19

  
20
sub _get_stock_onhand {
21
  my (%params) = @_;
22

  
23
  my $onhand_mode = !!$params{onhand};
24

  
25
  my @selects = ('SUM(qty) as qty');
26
  my @values;
27
  my @where;
28
  my @groups;
29

  
30
  if ($params{part}) {
31
    my @ids = map { ref $_ ? $_->id : $_ } listify($params{part});
32
    push @where, sprintf "parts_id IN (%s)", join ', ', ("?") x @ids;
33
    push @values, @ids;
34
  }
35

  
36
  if ($params{bin}) {
37
    my @ids = map { ref $_ ? $_->id : $_ } listify($params{bin});
38
    push @where, sprintf "bin_id IN (%s)", join ', ', ("?") x @ids;
39
    push @values, @ids;
40
  }
41

  
42
  if ($params{warehouse}) {
43
    my @ids = map { ref $_ ? $_->id : $_ } listify($params{warehouse});
44
    push @where, sprintf "warehouse.id IN (%s)", join ', ', ("?") x @ids;
45
    push @values, @ids;
46
  }
47

  
48
  if ($params{chargenumber}) {
49
    my @ids = listify($params{chargenumber});
50
    push @where, sprintf "chargenumber IN (%s)", join ', ', ("?") x @ids;
51
    push @values, @ids;
52
  }
53

  
54
  if ($params{date}) {
55
    push @where, sprintf "shippingdate <= ?";
56
    push @values, $params{date};
57
  }
58

  
59
  if ($params{bestbefore}) {
60
    push @where, sprintf "bestbefore >= ?";
61
    push @values, $params{bestbefore};
62
  }
63

  
64
  # reserve_warehouse
65
  if ($params{onhand} && !$params{warehouse}) {
66
    push @where, 'NOT warehouse.forreserve';
67
  }
68

  
69
  # reserve_for
70
  if ($params{onhand} && !$params{reserve_for}) {
71
    push @where, 'reserve_for_id IS NULL AND reserve_for_table IS NULL';
72
  }
73

  
74
  if ($params{reserve_for}) {
75
    my @objects = listify($params{chargenumber});
76
    my @tokens;
77
    push @tokens, ( "(reserve_for_id = ? AND reserve_for_table = ?)") x @objects;
78
    push @values, map { ($_->id, $_->meta->table) } @objects;
79
    push @where, '(' . join(' OR ', @tokens) . ')';
80
  }
81

  
82
  # by
83
  my %allowed_by = (
84
    part          => [ qw(parts_id) ],
85
    bin           => [ qw(bin_id inventory.warehouse_id warehouse.forreserve)],
86
    warehouse     => [ qw(inventory.warehouse_id warehouse.forreserve) ],
87
    chargenumber  => [ qw(chargenumber) ],
88
    bestbefore    => [ qw(bestbefore) ],
89
    reserve_for   => [ qw(reserve_for_id reserve_for_table) ],
90
    for_allocate  => [ qw(parts_id bin_id inventory.warehouse_id warehouse.forreserve chargenumber bestbefore reserve_for_id reserve_for_table) ],
91
  );
92

  
93
  if ($params{by}) {
94
    for (listify($params{by})) {
95
      my $selects = $allowed_by{$_} or Carp::croak("unknown option for by: $_");
96
      push @selects, @$selects;
97
      push @groups,  @$selects;
98
    }
99
  }
100

  
101
  my $select   = join ',', @selects;
102
  my $where    = @where  ? 'WHERE ' . join ' AND ', @where : '';
103
  my $group_by = @groups ? 'GROUP BY ' . join ', ', @groups : '';
104

  
105
  my $query = <<"";
106
    SELECT $select FROM inventory
107
    LEFT JOIN bin ON bin_id = bin.id
108
    LEFT JOIN warehouse ON bin.warehouse_id = warehouse.id
109
    $where
110
    $group_by
111
    HAVING SUM(qty) > 0
112

  
113
  my $results = selectall_hashref_query($::form, SL::DB->client->dbh, $query, @values);
114

  
115
  my %with_objects = (
116
    part         => 'SL::DB::Manager::Part',
117
    bin          => 'SL::DB::Manager::Bin',
118
    warehouse    => 'SL::DB::Manager::Warehouse',
119
    reserve_for  => undef,
120
  );
121

  
122
  my %slots = (
123
    part      =>  'parts_id',
124
    bin       =>  'bin_id',
125
    warehouse =>  'warehouse_id',
126
  );
127

  
128
  if ($params{by} && $params{with_objects}) {
129
    for my $with_object (listify($params{with_objects})) {
130
      Carp::croak("unknown with_object $with_object") if !exists $with_objects{$with_object};
131

  
132
      if (my $manager = $with_objects{$with_object}) {
133
        my $slot = $slots{$with_object};
134
        next if !(my @ids = map { $_->{$slot} } @$results);
135
        my $objects = $manager->get_all(query => [ id => \@ids ]);
136
        my %objects_by_id = map { $_->id => $_ } @$objects;
137

  
138
        $_->{$with_object} = $objects_by_id{$_->{$slot}} for @$results;
139
      } else {
140
        # need to fetch all reserve_for_table partitions
141
      }
142
    }
143
  }
144

  
145
  if ($params{by}) {
146
    return $results;
147
  } else {
148
    return $results->[0]{qty};
149
  }
150
}
151

  
152
sub get_stock {
153
  _get_stock_onhand(@_, onhand => 0);
154
}
155

  
156
sub get_onhand {
157
  _get_stock_onhand(@_, onhand => 1);
158
}
159

  
160
sub allocate {
161
  my (%params) = @_;
162

  
163
  my $part = $params{part} or Carp::croak('allocate needs a part');
164
  my $qty  = $params{qty}  or Carp::croak('allocate needs a qty');
165

  
166
  return () if $qty <= 0;
167

  
168
  my $results = get_stock(part => $part, by => 'for_allocate');
169
  my %bin_whitelist = map { (ref $_ ? $_->id : $_) => 1 } listify($params{bin});
170
  my %wh_whitelist  = map { (ref $_ ? $_->id : $_) => 1 } listify($params{warehouse});
171
  my %chargenumbers = map { (ref $_ ? $_->id : $_) => 1 } listify($params{chargenumber});
172
  my %reserve_whitelist;
173
  if ($params{reserve_for}) {
174
    $reserve_whitelist{ $_->meta->table }{ $_->id } = 1 for listify($params{reserve_for});
175
  }
176

  
177
  # filter the results. we don't want:
178
  # - negative amounts
179
  # - bins that are reserve but not in the white-list of warehouses or bins
180
  # - reservations that are not white-listed
181

  
182
  my @filtered_results = grep {
183
       (!$_->{forreserve} || $bin_whitelist{$_->{bin_id}} || $wh_whitelist{$_->{warehouse_id}})
184
    && (!$_->{reserve_for_id} || $reserve_whitelist{ $_->{reserve_for_table} }{ $_->{reserve_for_id} })
185
  } @$results;
186

  
187
  # sort results so that reserve_for is first, then chargenumbers, then wanted bins, then wanted warehouses
188
  my @sorted_results = sort {
189
       (!!$b->{reserve_for_id})    <=> (!!$a->{reserve_for_id})                   # sort by existing reserve_for_id first.
190
    || $chargenumbers{$b->{chargenumber}}  <=> $chargenumbers{$a->{chargenumber}} # then prefer wanted chargenumbers
191
    || $bin_whitelist{$b->{bin_id}}        <=> $bin_whitelist{$a->{bin_id}}       # then prefer wanted bins
192
    || $wh_whitelist{$b->{warehouse_id}}   <=> $wh_whitelist{$a->{warehouse_id}}  # then prefer wanted bins
193
  } @filtered_results;
194

  
195
  my @allocations;
196
  my $rest_qty = $qty;
197

  
198
  for my $chunk (@sorted_results) {
199
    my $qty = min($chunk->{qty}, $rest_qty);
200
    if ($qty > 0) {
201
      push @allocations, SL::Helper::Inventory::Allocation->new(
202
        parts_id          => $chunk->{parts_id},
203
        qty               => $qty,
204
        comment           => $params{comment},
205
        bin_id            => $chunk->{bin_id},
206
        warehouse_id      => $chunk->{warehouse_id},
207
        chargenumber      => $chunk->{chargenumber},
208
        bestbefore        => $chunk->{bestbefore},
209
        reserve_for_id    => $chunk->{reserve_for_id},
210
        reserve_for_table => $chunk->{reserve_for_table},
211
      );
212
      $rest_qty -= $qty;
213
    }
214

  
215
    last if $rest_qty == 0;
216
  }
217

  
218
  if ($rest_qty > 0) {
219
    die SL::X::Inventory::Allocation->new(
220
      error => 'not enough to allocate',
221
      msg => t8("can not allocate #1 units of #2, missing #3 units", $qty, $part->displayable_name, $rest_qty),
222
    );
223
  } else {
224
    return @allocations;
225
  }
226
}
227

  
228
sub allocate_for_assembly {
229
  my (%params) = @_;
230

  
231
  my $part = $params{part} or Carp::croak('allocate needs a part');
232
  my $qty  = $params{qty}  or Carp::croak('allocate needs a qty');
233

  
234
  Carp::croak('not an assembly') unless $part->is_assembly;
235

  
236
  my %parts_to_allocate;
237

  
238
  for my $assembly ($part->assemblies) {
239
    $parts_to_allocate{ $assembly->part->id } //= 0;
240
    $parts_to_allocate{ $assembly->part->id } += $assembly->qty * $qty; # TODO recipe factor
241
  }
242

  
243
  my @allocations;
244

  
245
  for my $part_id (keys %parts_to_allocate) {
246
    my $part = SL::DB::Part->new(id => $part_id);
247
    push @allocations, allocate(%params, part => $part, qty => $parts_to_allocate{$part_id});
248
  }
249

  
250
  @allocations;
251
}
252

  
253
sub produce_assembly {
254
  my (%params) = @_;
255

  
256
  my $part = $params{part} or Carp::croak('allocate needs a part');
257
  my $qty  = $params{qty}  or Carp::croak('allocate needs a qty');
258

  
259
  my $allocations = $params{allocations};
260
  if (!$allocations && $params{auto_allocate}) {
261
    $allocations = [ allocate_for_assembly(part => $part, qty => $qty) ];
262
  } else {
263
    Carp::croak("need allocations or auto_allocate to produce something") unless $allocations;
264
  }
265

  
266
  my $bin          = $params{bin} or Carp::croak("need target bin");
267
  my $chargenumber = $params{chargenumber};
268
  my $bestbefore   = $params{bestbefore};
269
  my $comment      = $params{comment} // '';
270

  
271
  my $production_order_item = $params{production_order_item};
272
  my $invoice               = $params{invoice};
273
  my $project               = $params{project};
274
  my $reserve_for           = $params{reserve_for};
275

  
276
  my $reserve_for_id    = $reserve_for ? $reserve_for->id          : undef;
277
  my $reserve_for_table = $reserve_for ? $reserve_for->meta->table : undef;
278

  
279
  my $shippingdate = $params{shippingsdate} // DateTime->now_local;
280

  
281
  my $trans_id              = $params{trans_id};
282
  ($trans_id) = selectrow_query($::form, SL::DB->client->dbh, qq|SELECT nextval('id')| ) unless $trans_id;
283

  
284
  my $trans_type_out = SL::DB::Manager::TransferType->find_by(direction => 'out', description => 'used');
285
  my $trans_type_in  = SL::DB::Manager::TransferType->find_by(direction => 'in', description => 'assembled');
286

  
287
  # check whether allocations are sane
288
  if (!$params{no_check_allocations} && !$params{auto_allocate}) {
289
    my %allocations_by_part = map { $_->parts_id  => $_->qty } @$allocations;
290
    for my $assembly ($part->assemblies) {
291
      $allocations_by_part{ $assembly->parts_id } -= $assembly->qty * $qty; # TODO recipe factor
292
    }
293

  
294
    die "allocations are insufficient for production" if any { $_ < 0 } values %allocations_by_part;
295
  }
296

  
297
  my @transfers;
298
  for my $allocation (@$allocations) {
299
    push @transfers, SL::DB::Inventory->new(
300
      trans_id     => $trans_id,
301
      %$allocation,
302
      qty          => -$allocation->qty,
303
      trans_type   => $trans_type_out,
304
      shippingdate => $shippingdate,
305
      employee     => SL::DB::Manager::Employee->current,
306
    );
307
  }
308

  
309
  push @transfers, SL::DB::Inventory->new(
310
    trans_id          => $trans_id,
311
    trans_type        => $trans_type_in,
312
    part              => $part,
313
    qty               => $qty,
314
    bin               => $bin,
315
    warehouse         => $bin->warehouse_id,
316
    chargenumber      => $chargenumber,
317
    bestbefore        => $bestbefore,
318
    reserve_for_id    => $reserve_for_id,
319
    reserve_for_table => $reserve_for_table,
320
    shippingdate      => $shippingdate,
321
    project           => $project,
322
    invoice           => $invoice,
323
    comment           => $comment,
324
    prod              => $production_order_item,
325
    employee          => SL::DB::Manager::Employee->current,
326
  );
327

  
328
  SL::DB->client->with_transaction(sub {
329
    $_->save for @transfers;
330
    1;
331
  }) or do {
332
    die SL::DB->client->error;
333
  };
334

  
335
  @transfers;
336
}
337

  
338
package SL::Helper::Inventory::Allocation {
339
  my @attributes = qw(parts_id qty bin_id warehouse_id chargenumber bestbefore comment reserve_for_id reserve_for_table);
340
  my %attributes = map { $_ => 1 } @attributes;
341

  
342
  for my $name (@attributes) {
343
    no strict 'refs';
344
    *{"WH::Allocation::$name"} = sub { $_[0]{$name} };
345
  }
346

  
347
  sub new {
348
    my ($class, %params) = @_;
349

  
350
    Carp::croak("missing attribute $_") for grep { !exists $params{$_}     } @attributes;
351
    Carp::croak("unknown attribute $_") for grep { !exists $attributes{$_} } keys %params;
352
    Carp::croak("$_ must be set")       for grep { !$params{$_} } qw(parts_id qty bin_id);
353
    Carp::croak("$_ must be positive")  for grep { !($params{$_} > 0) } qw(parts_id qty bin_id);
354

  
355
    bless { %params }, $class;
356
  }
357
}
358

  
359
1;
360

  
361
=encoding utf-8
362

  
363
=head1 NAME
364

  
365
SL::WH - Warehouse and Inventory API
366

  
367
=head1 SYNOPSIS
368

  
369
  # See description for an intro to the concepts used here.
370

  
371
  use SL::Helper::Inventory;
372

  
373
  # stock, get "what's there" for a part with various conditions:
374
  my $qty = SL::Helper::Inventory->get_stock(part => $part);                              # how much is on stock?
375
  my $qty = SL::Helper::Inventory->get_stock(part => $part, date => $date);               # how much was on stock at a specific time?
376
  my $qty = SL::Helper::Inventory->get_stock(part => $part, bin => $bin);                 # how is on stock in a specific bin?
377
  my $qty = SL::Helper::Inventory->get_stock(part => $part, warehouse => $warehouse);     # how is on stock in a specific warehouse?
378
  my $qty = SL::Helper::Inventory->get_stock(part => $part, chargenumber => $chargenumber); # how is on stock of a specific chargenumber?
379

  
380
  # onhand, get "what's available" for a part with various conditions:
381
  my $qty = SL::Helper::Inventory->get_onhand(part => $part);                              # how much is available?
382
  my $qty = SL::Helper::Inventory->get_onhand(part => $part, date => $date);               # how much was available at a specific time?
383
  my $qty = SL::Helper::Inventory->get_onhand(part => $part, bin => $bin);                 # how much is available in a specific bin?
384
  my $qty = SL::Helper::Inventory->get_onhand(part => $part, warehouse => $warehouse);     # how much is available in a specific warehouse?
385
  my $qty = SL::Helper::Inventory->get_onhand(part => $part, chargenumber => $chargenumber); # how much is availbale of a specific chargenumber?
386
  my $qty = SL::Helper::Inventory->get_onhand(part => $part, reserve_for => $order);       # how much is available if you include this reservation?
387

  
388
  # onhand batch mode:
389
  my $data = SL::Helper::Inventory->get_onhand(
390
    warehouse    => $warehouse,
391
    by           => [ qw(bin part chargenumber reserve_for) ],
392
    with_objects => [ qw(bin part) ],
393
  );
394

  
395
  # allocate:
396
  my @allocations, SL::Helper::Inventory->allocate(
397
    part         => $part,          # part_id works too
398
    qty          => $qty,           # must be positive
399
    chargenumber => $chargenumber,  # optional, may be arrayref. if provided these charges will be used first
400
    bestbefore   => $datetime,      # optional, defaults to today. items with bestbefore prior to that date wont be used
401
    reserve_for  => $object,        # optional, may be arrayref. if provided the qtys reserved for these objects will be used first
402
    bin          => $bin,           # optional, may be arrayref. if provided
403
  );
404

  
405
  # shortcut to allocate all that is needed for producing an assembly, will use chargenumbers as appropriate
406
  my @allocations, SL::Helper::Inventory->allocate_for_assembly(
407
    part         => $assembly,      # part_id works too
408
    qty          => $qty,           # must be positive
409
  );
410

  
411
  # create allocation manually, bypassing checks, all of these need to be passed, even undefs
412
  my $allocation = SL::Helper::Inventory::Allocation->new(
413
    part_id           => $part->id,
414
    qty               => 15,
415
    bin_id            => $bin_obj->id,
416
    warehouse_id      => $bin_obj->warehouse_id,
417
    chargenumber      => '1823772365',
418
    bestbefore        => undef,
419
    reserve_for_id    => undef,
420
    reserve_for_table => undef,
421
  );
422

  
423
  # produce_assembly:
424
  SL::Helper::Inventory->produce_assembly(
425
    part         => $part,           # target assembly
426
    qty          => $qty,            # qty
427
    allocations  => \@allocations,   # allocations to use. alternatively use "auto_allocate => 1,"
428

  
429
    # where to put it
430
    bin          => $bin,           # needed unless a global standard target is configured
431
    chargenumber => $chargenumber,  # optional
432
    bestbefore   => $datetime,      # optional
433
    comment      => $comment,       # optional
434

  
435
    # links, all optional
436
    production_order_item => $item,
437
    reserve_for           => $object,
438
  );
439

  
440
=head1 DESCRIPTION
441

  
442
New functions for the warehouse and inventory api.
443

  
444
The WH api currently has three large shortcomings. It is very hard to just get
445
the current stock for an item, it's extremely complicated to use it to produce
446
assemblies while ensuring that no stock ends up negative, and it's very hard to
447
use it to get an overview over the actual contents of the inventory.
448

  
449
The first problem has spawned several dozen small functions in the program that
450
try to implement that, and those usually miss some details. They may ignore
451
reservations, or reserve warehouses, or bestbefore times.
452

  
453
To get this cleaned up a bit this code introduces two concepts: stock and onhand.
454

  
455
Stock is defined as the actual contents of the inventory, everything that is
456
there. Onhand is what is available, which means things that are stocked and not
457
reserved and not expired.
458

  
459
The two new functions C<get_stock> and C<get_onhand> encapsulate these principles and
460
allow simple access with some optional filters for chargenumbers or warehouses.
461
Both of them have a batch mode that can be used to get these information to
462
supllement smiple reports.
463

  
464
To address the safe assembly creation a new function has been added.
465
C<allocate> will try to find the requested quantity of a part in the inventory
466
and will return allocations of it which can then be used to create the
467
assembly. Allocation will happen with the C<onhand> semantics defined above,
468
meaning that by default no reservations or expired goods will be used. The
469
caller can supply hints of what shold be used and in those cases chargenumber
470
and reservations will be used up as much as possible first.  C<allocate> will
471
always try to fulfil the request even beyond those. Should the required amount
472
not be stocked, allocate will throw an exception.
473

  
474
C<produce_assembly> has been rewritten to only accept parameters about the
475
target of the production, and requires allocations to complete the request. The
476
allocations can be supplied manually, or can be generated automatically.
477
C<produce_assembly> will check whether enough allocations are given to create
478
the recipe, but will not check whether the allocations are backed. If the
479
allocations are not sufficient or if the auto-allocation fails an exception
480
is returned. If you need to produce something that is not in the inventory, you
481
can bypass those checks by creating the allocations yourself (see
482
L</"ALLOCATION DATA STRUCTURE">).
483

  
484
Note: this is only intended to cover the scenarios described above. For other cases:
485

  
486
=over 4
487

  
488
=item *
489

  
490
If you need the reserved amount for an order use C<SL::DB::Helper::Reservation>
491
instead.
492

  
493
=item *
494

  
495
If you need actual inventory objects because of record links, prod_id links or
496
something like that load them directly. And strongly consider redesigning that,
497
because it's really fragile.
498

  
499
=item *
500

  
501
You need weight or accounting information you're on your own. The inventory api
502
only concerns itself with the raw quantities.
503

  
504
=item *
505

  
506
If you need the first stock date of parts, or anything related to a specific
507
transfer type or direction, this is not covered yet.
508

  
509
=back
510

  
511
=head1 FUNCTIONS
512

  
513
=over 4
514

  
515
=item * get_stock PARAMS
516

  
517
Returns for single parts how much actually exists in the inventory.
518

  
519
Options:
520

  
521
=over 4
522

  
523
=item * part
524

  
525
The part. Must be present without C<by>. May be arrayref with C<by>. Can be object or id.
526

  
527
=item * bin
528

  
529
If given, will only return stock on these bins. Optional. May be array, May be object or id.
530

  
531
=item * warehouse
532

  
533
If given, will only return stock on these warehouses. Optional. May be array, May be object or id.
534

  
535
=item * date
536

  
537
If given, will return stock as it were on this timestamp. Optional. Must be L<DateTime> object.
538

  
539
=item * chargenumber
540

  
541
If given, will only show stock with this chargenumber. Optional. May be array.
542

  
543
=item * by
544

  
545
See L</"STOCK/ONHAND REPORT MODE">
546

  
547
=item * with_objects
548

  
549
See L</"STOCK/ONHAND REPORT MODE">
550

  
551
=back
552

  
553
Will return a single qty normally, see L</"STOCK/ONHAND REPORT MODE"> for batch
554
mode when C<by> is given.
555

  
556
=item * get_onhand PARAMS
557

  
558
Returns for single parts how much is available in the inventory. That excludes:
559
reserved quantities, reserved warehouses and stock with expired bestbefore.
560

  
561
It takes all options of L</get_stock> but treats some of the differently and has some additional ones:
562

  
563
=over 4
564

  
565
=item * warehouse
566

  
567
Usually C<onhand> will not include results from warehouses with the C<reserve>
568
flag. However giving an explicit list of warehouses will include there in the
569
search, as well as all others.
570

  
571
=item * reserve_for
572

  
573
=item * reserve_warehouse
574

  
575
=item * bestbefore
576

  
577
=back
578

  
579
=item * allocate PARAMS
580

  
581
Accepted parameters:
582

  
583
=over 4
584

  
585
=item * part
586

  
587
=item * qty
588

  
589
=item * bin
590

  
591
Bin object. Optional.
592

  
593
=item * warehouse
594

  
595
Warehouse object. Optional.
596

  
597
=item * chargenumber
598

  
599
Optional.
600

  
601
=item * bestbefore
602

  
603
Datetime. Optional.
604

  
605
=item * reserve_for
606

  
607
Needs to be a rose object, where id and table can be extracted. Optional.
608

  
609
=back
610

  
611
Tries to allocate the required quantity using what is currently onhand. If
612
given any of C<bin>, C<warehouse>, C<chargenumber>, C<reserve_for>
613

  
614

  
615
=item * allocate_for_assembly PARAMS
616

  
617
Shortcut to allocate everything for an assembly. Takes the same arguments. Will
618
compute the required amount for each assembly part and allocate all of them.
619

  
620
=item * produce_assembly
621

  
622

  
623
=back
624

  
625
=head1 STOCK/ONHAND REPORT MODE
626

  
627
If the special option C<by> is given with an arrayref, the result will instead
628
be an arrayref of partitioned stocks by those fields. Valid partitions are:
629

  
630
=over 4
631

  
632
=item * part
633

  
634
If this is given, part is optional in the parameters
635

  
636
=item * bin
637

  
638
=item * warehouse
639

  
640
=item * chargenumber
641

  
642
=item * bestbefore
643

  
644
=item * reserve_for
645

  
646
=back
647

  
648
Note: If you want to use the returned data to create allocations you I<need> to
649
enable all of these. To make this easier a special shortcut exists
650

  
651
In this mode, C<with_objects> can be used to load C<warehouse>, C<bin>,
652
C<parts>, and the C<reserve_for> objects in one go, just like with Rose. They
653
need to be present in C<by> before that though.
654

  
655
=head1 ALLOCATION ALGORITHM
656

  
657
When calling allocate, the current onhand (== available stock) of the item will
658
be used to decide which bins/chargenumbers/bestbefore can be used.
659

  
660
In general allocate will try to make the request happen, and will use the
661
provided charges up first, and then tap everything else. If you need to only
662
I<exactly> use the provided charges, you'll need to craft the allocations
663
yourself. See L</"ALLOCATION DATA STRUCTURE"> for that.
664

  
665
If C<reserve_for> is given, those will be used up first too.
666

  
667
If C<reserved_warehouse> is given, those will be used up second.
668

  
669
If C<chargenumber> is given, those will be used up next.
670

  
671
After that normal quantities will be used.
672

  
673
These are tiebreakers and expected to rarely matter in reality. If you need
674
finegrained control over which allocation is used, you may want to get the
675
onhands yourself and select the appropriate ones.
676

  
677
Only quantities with C<bestbefore> unset or after the given date will be
678
considered. If more than one charge is eligible, the earlier C<bestbefore>
679
will be used.
680

  
681
Allocations do NOT have an internal memory and can't react to other allocations
682
of the same part earlier. Never double allocate the same part within a
683
transaction.
684

  
685
=head1 ALLOCATION DATA STRUCTURE
686

  
687
Allocations are instances of the helper class C<SL::Helper::Inventory::Allocation>. They require
688
each of the following attributes to be set at creation time:
689

  
690
=over 4
691

  
692
=item * parts_id
693

  
694
=item * qty
695

  
696
=item * bin_id
697

  
698
=item * warehouse_id
699

  
700
=item * chargenumber
701

  
702
=item * bestbefore
703

  
704
=item * reserve_for_id
705

  
706
=item * reserve_for_table
707

  
708
=back
709

  
710
C<chargenumber>, C<bestbefore>, C<reserve_for_id> and C<reserve_for_table> may
711
be C<undef> (but must still be present at creation time). Instances are
712
considered immutable.
713

  
714
=head1 ERROR HANDLING
715

  
716
C<allocate> and C<produce_assembly> will throw exceptions if the request can
717
not be completed. The usual reason will be insufficient onhand to allocate, or
718
insufficient allocations to process the request.
719

  
720
=head1 TODO
721

  
722
  * define and describe error classes
723
  * define wrapper classes for stock/onhand batch mode return values
724
  * handle extra arguments in produce: shippingdate, project, oe
725
  * clean up allocation helper class
726
  * with objects for reservations
727
  * document no_ check
728
  * tests
729

  
730
=head1 BUGS
731

  
732
None yet :)
733

  
734
=head1 AUTHOR
735

  
736
Sven Schöling E<lt>sven.schoeling@opendynamic.deE<gt>
737

  
738
=cut
SL/X.pm
30 30
  'SL::X::ZUGFeRDValidation' => {
31 31
    isa                 => 'SL::X::Base',
32 32
  },
33
  'SL::X::Inventory' => {
34
    isa                 => 'SL::X::Base',
35
    fields              => [ qw(msg error) ],
36
    defaults            => { error_template => [ '%s: %s', qw(msg error) ] },
37
  },
38
  'SL::X::Inventory::Allocation' => {
39
    isa                 => 'SL::X::Base',
40
    fields              => [ qw(msg error) ],
41
    defaults            => { error_template => [ '%s: %s', qw(msg error) ] },
42
  },
33 43
);
34 44

  
35 45
1;
t/wh/inventory.t
1
use strict;
2
use Test::More;
3

  
4
use lib 't';
5

  
6
use SL::Dev::Part qw(new_part new_assembly);
7
use SL::Dev::Inventory qw(create_warehouse_and_bins set_stock);
8
use SL::Dev::Record qw(create_sales_order);
9
use SL::DB::Helper::Reservation qw(make_reservation);
10

  
11
use_ok 'Support::TestSetup';
12
use_ok 'SL::DB::Bin';
13
use_ok 'SL::DB::Part';
14
use_ok 'SL::DB::Warehouse';
15
use_ok 'SL::DB::Inventory';
16
use_ok 'SL::WH';
17
use_ok 'SL::Helper::Inventory';
18

  
19
Support::TestSetup::login();
20

  
21
my ($wh, $bin1, $bin2, $assembly1);
22

  
23
reset_db();
24
create_standard_stock();
25

  
26

  
27
# simple stock in, get_stock, get_onhand
28
set_stock(
29
  part => $assembly1,
30
  qty => 25,
31
  bin => $bin1,
32
);
33

  
34
is(SL::Helper::Inventory::get_stock(part => $assembly1), "25.00000", 'simple get_stock works');
35
is(SL::Helper::Inventory::get_onhand(part => $assembly1), "25.00000", 'simple get_onhand works');
36

  
37
# stock on some more, get_stock, get_onhand
38

  
39
WH->transfer({
40
  parts_id          => $assembly1->id,
41
  qty               => 15,
42
  transfer_type     => 'stock',
43
  dst_warehouse_id  => $bin1->warehouse_id,
44
  dst_bin_id        => $bin1->id,
45
  comment           => 'more',
46
});
47

  
48
WH->transfer({
49
  parts_id          => $assembly1->id,
50
  qty               => 20,
51
  transfer_type     => 'stock',
52
  chargenumber      => '298345',
53
  dst_warehouse_id  => $bin1->warehouse_id,
54
  dst_bin_id        => $bin1->id,
55
  comment           => 'more',
56
});
57

  
58
is(SL::Helper::Inventory::get_stock(part => $assembly1), "60.00000", 'normal get_stock works');
59
is(SL::Helper::Inventory::get_onhand(part => $assembly1), "60.00000", 'normal get_onhand works');
60

  
61
# reserve some of it, get_stock, get_onhand
62

  
63
my $order = create_sales_order(save => 1);
64

  
65
make_reservation(
66
  part        => $assembly1,
67
  bin         => $bin1,
68
  reserve_for => $order,
69
  qty         => 25,
70
);
71

  
72
is(WH->get_stock_(part => $assembly1), "60.00000", 'normal get_stock works');
73
is(WH->get_onhand_(part => $assembly1), "35.00000", 'normal get_onhand works');
74

  
75
# allocate some stuff
76

  
77
my @allocations = SL::Helper::Inventory::allocate(
78
  part => $assembly1,
79
  qty  => 12,
80
);
81

  
82
is_deeply(\%{ $allocations[0] }, {
83
   bestbefore        => undef,
84
   bin_id            => $bin1->id,
85
   chargenumber      => '',
86
   parts_id          => $assembly1->id,
87
   qty               => 12,
88
   reserve_for_id    => undef,
89
   reserve_for_table => undef,
90
   warehouse_id      => $wh->id,
91
 }, 'allocatiion works');
92

  
93
# simple
94

  
95
# with reservation
96

  
97
# more than exists
98

  
99
# produce something
100

  
101
# produce the same using auto_allocation
102

  
103

  
104
sub reset_db {
105
  SL::DB::Manager::Order->delete_all(all => 1);
106
  SL::DB::Manager::Inventory->delete_all(all => 1);
107
  SL::DB::Manager::Assembly->delete_all(all => 1);
108
  SL::DB::Manager::Part->delete_all(all => 1);
109
  SL::DB::Manager::Bin->delete_all(all => 1);
110
  SL::DB::Manager::Warehouse->delete_all(all => 1);
111
}
112

  
113
sub create_standard_stock {
114
  ($wh, $bin1) = create_warehouse_and_bins();
115
  $bin2 = SL::DB::Bin->new(description => "Bin 2", warehouse => $wh)->save;
116
  $wh->load;
117

  
118
  $assembly1  =  new_assembly()->save;
119
}
120

  
121

  
122
reset();
123

  
124
done_testing();
125

  
126
1;

Auch abrufbar als: Unified diff