GoBD: IDs von Kunden/Lieferanten auch so nennen.
[kivitendo-erp.git] / SL / GoBD.pm
1 package SL::GoBD;
2
3 # TODO:
4 # optional: background jobable
5
6 use strict;
7 use utf8;
8
9 use parent qw(Rose::Object);
10
11 use Text::CSV_XS;
12 use XML::Writer;
13 use Archive::Zip;
14 use File::Temp ();
15 use File::Spec ();
16 use List::MoreUtils qw(any);
17 use List::UtilsBy qw(partition_by sort_by);
18
19 use SL::DB::Helper::ALL; # since we work on meta data, we need everything
20 use SL::DB::Helper::Mappings;
21 use SL::Locale::String qw(t8);
22
23 use Rose::Object::MakeMethods::Generic (
24   scalar                  => [ qw(from to writer company location) ],
25   'scalar --get_set_init' => [ qw(files tempfiles export_ids tables csv_headers) ],
26 );
27
28 # in this we find:
29 # key:         table name
30 # name:        short name, translated
31 # description: long description, translated
32 # columns:     list of columns to export. export all columns if not present
33 # primary_key: override primary key
34 my %known_tables = (
35   chart    => { name => t8('Charts'),    description => t8('Chart of Accounts'),    primary_key => 'accno', columns => [ qw(id accno description) ],     },
36   customer => { name => t8('Customers'), description => t8('Customer Master Data'), columns => [ qw(id customernumber name department_1 department_2 street zipcode city country contact phone fax email notes taxnumber obsolete ustid) ] },
37   vendor   => { name => t8('Vendors'),   description => t8('Vendor Master Data'),   columns => [ qw(id vendornumber name department_1 department_2 street zipcode city country contact phone fax email notes taxnumber obsolete ustid) ] },
38 );
39
40 my %column_titles = (
41    chart => {
42      id             => t8('ID'),
43      accno          => t8('Account Number'),
44      description    => t8('Description'),
45    },
46    customer_vendor => {
47      id             => t8('ID (lit)'),
48      name           => t8('Name'),
49      department_1   => t8('Department 1'),
50      department_2   => t8('Department 2'),
51      street         => t8('Street'),
52      zipcode        => t8('Zipcode'),
53      city           => t8('City'),
54      country        => t8('Country'),
55      contact        => t8('Contact'),
56      phone          => t8('Phone'),
57      fax            => t8('Fax'),
58      email          => t8('E-mail'),
59      notes          => t8('Notes'),
60      customernumber => t8('Customer Number'),
61      vendornumber   => t8('Vendor Number'),
62      taxnumber      => t8('Tax Number'),
63      obsolete       => t8('Obsolete'),
64      ustid          => t8('Tax ID number'),
65    },
66 );
67 $column_titles{$_} = $column_titles{customer_vendor} for qw(customer vendor);
68
69 my %datev_column_defs = (
70   trans_id          => { type => 'Rose::DB::Object::Metadata::Column::Integer', text => t8('ID'), },
71   amount            => { type => 'Rose::DB::Object::Metadata::Column::Numeric', text => t8('Amount'), },
72   credit_accname    => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Credit Account Name'), },
73   credit_accno      => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Credit Account'), },
74   debit_accname     => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Debit Account Name'), },
75   debit_accno       => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Debit Account'), },
76   invnumber         => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Reference'), },
77   name              => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Name'), },
78   notes             => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Notes'), },
79   tax               => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Tax'), },
80   taxdescription    => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('tax_taxdescription'), },
81   taxkey            => { type => 'Rose::DB::Object::Metadata::Column::Integer', text => t8('Taxkey'), },
82   tax_accname       => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Tax Account Name'), },
83   tax_accno         => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Tax Account'), },
84   transdate         => { type => 'Rose::DB::Object::Metadata::Column::Date',    text => t8('Invoice Date'), },
85   vcnumber          => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Customer/Vendor Number'), },
86   customer_id       => { type => 'Rose::DB::Object::Metadata::Column::Integer', text => t8('Customer (database ID)'), },
87   vendor_id         => { type => 'Rose::DB::Object::Metadata::Column::Integer', text => t8('Vendor (database ID)'), },
88   itime             => { type => 'Rose::DB::Object::Metadata::Column::Date',    text => t8('Create Date'), },
89 );
90
91 my @datev_columns = qw(
92   trans_id
93   customer_id vendor_id
94   name           vcnumber
95   transdate    invnumber      amount
96   debit_accno  debit_accname
97   credit_accno credit_accname
98   taxdescription tax
99   tax_accno    tax_accname    taxkey
100   notes itime
101 );
102
103 # rows in this listing are tiers.
104 # tables may depend on ids in a tier above them
105 my @export_table_order = qw(
106   ar ap gl oe delivery_orders
107   invoice orderitems delivery_order_items
108   customer vendor
109   parts
110   acc_trans
111   chart
112 );
113
114 # needed because the standard dbh sets datestyle german and we don't want to mess with that
115 my $date_format = 'DD.MM.YYYY';
116 my $number_format = '1000.00';
117
118 my $myconfig = { numberformat => $number_format };
119
120 # callbacks that produce the xml spec for these column types
121 my %column_types = (
122   'Rose::DB::Object::Metadata::Column::Integer'   => sub { $_[0]->tag('Numeric') },  # see Caveats for integer issues
123   'Rose::DB::Object::Metadata::Column::BigInt'    => sub { $_[0]->tag('Numeric') },  # see Caveats for integer issues
124   'Rose::DB::Object::Metadata::Column::Text'      => sub { $_[0]->tag('AlphaNumeric') },
125   'Rose::DB::Object::Metadata::Column::Varchar'   => sub { $_[0]->tag('AlphaNumeric') },
126   'Rose::DB::Object::Metadata::Column::Character' => sub { $_[0]->tag('AlphaNumeric') },
127   'Rose::DB::Object::Metadata::Column::Numeric'   => sub { $_[0]->tag('Numeric', sub { $_[0]->tag('Accuracy', 5) }) },
128   'Rose::DB::Object::Metadata::Column::Date'      => sub { $_[0]->tag('Date', sub { $_[0]->tag('Format', $date_format) }) },
129   'Rose::DB::Object::Metadata::Column::Timestamp' => sub { $_[0]->tag('Date', sub { $_[0]->tag('Format', $date_format) }) },
130   'Rose::DB::Object::Metadata::Column::Float'     => sub { $_[0]->tag('Numeric') },
131   'Rose::DB::Object::Metadata::Column::Boolean'   => sub { $_[0]
132     ->tag('AlphaNumeric')
133     ->tag('Map', sub { $_[0]
134       ->tag('From', 1)
135       ->tag('To', t8('true'))
136     })
137     ->tag('Map', sub { $_[0]
138       ->tag('From', 0)
139       ->tag('To', t8('false'))
140     })
141     ->tag('Map', sub { $_[0]
142       ->tag('From', '')
143       ->tag('To', t8('false'))
144     })
145   },
146 );
147
148 sub generate_export {
149   my ($self) = @_;
150
151   # verify data
152   $self->from && 'DateTime' eq ref $self->from or die 'need from date';
153   $self->to   && 'DateTime' eq ref $self->to   or die 'need to date';
154   $self->from <= $self->to                     or die 'from date must be earlier or equal than to date';
155   $self->tables && @{ $self->tables }          or die 'need tables';
156   for (@{ $self->tables }) {
157     next if $known_tables{$_};
158     die "unknown table '$_'";
159   }
160
161   # get data from those tables and save to csv
162   # for that we need to build queries that fetch all the columns
163   for ($self->sorted_tables) {
164     $self->do_csv_export($_);
165   }
166
167   $self->do_datev_csv_export;
168
169   # write xml file
170   $self->do_xml_file;
171
172   # add dtd
173   $self->files->{'gdpdu-01-08-2002.dtd'} = File::Spec->catfile('users', 'gdpdu-01-08-2002.dtd');
174
175   # make zip
176   my ($fh, $zipfile) = File::Temp::tempfile();
177   my $zip            = Archive::Zip->new;
178
179   while (my ($name, $file) = each %{ $self->files }) {
180     $zip->addFile($file, $name);
181   }
182
183   $zip->writeToFileHandle($fh) == Archive::Zip::AZ_OK() or die 'error writing zip file';
184   close($fh);
185
186   return $zipfile;
187 }
188
189 sub do_xml_file {
190   my ($self) = @_;
191
192   my ($fh, $filename) = File::Temp::tempfile();
193   binmode($fh, ':utf8');
194
195   $self->files->{'INDEX.XML'} = $filename;
196   push @{ $self->tempfiles }, $filename;
197
198   my $writer = XML::Writer->new(
199     OUTPUT      => $fh,
200     ENCODING    => 'UTF-8',
201   );
202
203   $self->writer($writer);
204   $self->writer->xmlDecl('UTF-8');
205   $self->writer->doctype('DataSet', undef, "gdpdu-01-08-2002.dtd");
206   $self->tag('DataSet', sub { $self
207     ->tag('Version', '1.0')
208     ->tag('DataSupplier', sub { $self
209       ->tag('Name', $self->client_name)
210       ->tag('Location', $self->client_location)
211       ->tag('Comment', $self->make_comment)
212     })
213     ->tag('Media', sub { $self
214       ->tag('Name', t8('DataSet #1', 1));
215       for (reverse $self->sorted_tables) { $self  # see CAVEATS for table order
216         ->table($_)
217       }
218       $self->do_datev_xml_table;
219     })
220   });
221   close($fh);
222 }
223
224 sub table {
225   my ($self, $table) = @_;
226   my $writer = $self->writer;
227
228   $self->tag('Table', sub { $self
229     ->tag('URL', "$table.csv")
230     ->tag('Name', $known_tables{$table}{name})
231     ->tag('Description', $known_tables{$table}{description})
232     ->tag('Validity', sub { $self
233       ->tag('Range', sub { $self
234         ->tag('From', $self->from->to_kivitendo(dateformat => 'dd.mm.yyyy'))
235         ->tag('To',   $self->to->to_kivitendo(dateformat => 'dd.mm.yyyy'))
236       })
237       ->tag('Format', $date_format)
238     })
239     ->tag('UTF8')
240     ->tag('DecimalSymbol', '.')
241     ->tag('DigitGroupingSymbol', '|')     # see CAVEATS in documentation
242     ->tag('Range', sub { $self
243       ->tag('From', $self->csv_headers ? 2 : 1)
244     })
245     ->tag('VariableLength', sub { $self
246       ->tag('ColumnDelimiter', ',')       # see CAVEATS for missing RecordDelimiter
247       ->tag('TextEncapsulator', '"')
248       ->columns($table)
249       ->foreign_keys($table)
250     })
251   });
252 }
253
254 sub _table_columns {
255   my ($table) = @_;
256   my $package = SL::DB::Helper::Mappings::get_package_for_table($table);
257
258   my %white_list;
259   my $use_white_list = 0;
260   if ($known_tables{$table}{columns}) {
261     $use_white_list = 1;
262     $white_list{$_} = 1 for @{ $known_tables{$table}{columns} || [] };
263   }
264
265   # PrimaryKeys must come before regular columns, so partition first
266   partition_by {
267     $known_tables{$table}{primary_key}
268       ? 1 * ($_ eq $known_tables{$table}{primary_key})
269       : 1 * $_->is_primary_key_member
270   } grep {
271     $use_white_list ? $white_list{$_->name} : 1
272   } $package->meta->columns;
273 }
274
275 sub columns {
276   my ($self, $table) = @_;
277
278   my %cols_by_primary_key = _table_columns($table);
279
280   for my $column (@{ $cols_by_primary_key{1} }) {
281     my $type = $column_types{ ref $column };
282
283     die "unknown col type @{[ ref $column ]}" unless $type;
284
285     $self->tag('VariablePrimaryKey', sub { $self
286       ->tag('Name', $column_titles{$table}{$column->name});
287       $type->($self);
288     })
289   }
290
291   for my $column (@{ $cols_by_primary_key{0} }) {
292     my $type = $column_types{ ref $column };
293
294     die "unknown col type @{[ ref $column]}" unless $type;
295
296     $self->tag('VariableColumn', sub { $self
297       ->tag('Name', $column_titles{$table}{$column->name});
298       $type->($self);
299     })
300   }
301
302   $self;
303 }
304
305 sub foreign_keys {
306   my ($self, $table) = @_;
307   my $package = SL::DB::Helper::Mappings::get_package_for_table($table);
308
309   my %requested = map { $_ => 1 } @{ $self->tables };
310
311   for my $rel ($package->meta->foreign_keys) {
312     next unless $requested{ $rel->class->meta->table };
313
314     # ok, now extract the columns used as foreign key
315     my %key_columns = $rel->key_columns;
316
317     if (1 != keys %key_columns) {
318       die "multi keys? we don't support this currently. fix it please";
319     }
320
321     if ($table eq $rel->class->meta->table) {
322       # self referential foreign keys are a PITA to export correctly. skip!
323       next;
324     }
325
326     $self->tag('ForeignKey', sub {
327       $_[0]->tag('Name',  $column_titles{$table}{$_}) for keys %key_columns;
328       $_[0]->tag('References', $rel->class->meta->table);
329    });
330   }
331 }
332
333 sub do_datev_xml_table {
334   my ($self) = @_;
335   my $writer = $self->writer;
336
337   $self->tag('Table', sub { $self
338     ->tag('URL', "transactions.csv")
339     ->tag('Name', t8('Transactions'))
340     ->tag('Description', t8('Transactions'))
341     ->tag('Validity', sub { $self
342       ->tag('Range', sub { $self
343         ->tag('From', $self->from->to_kivitendo(dateformat => 'dd.mm.yyyy'))
344         ->tag('To',   $self->to->to_kivitendo(dateformat => 'dd.mm.yyyy'))
345       })
346       ->tag('Format', $date_format)
347     })
348     ->tag('UTF8')
349     ->tag('DecimalSymbol', '.')
350     ->tag('DigitGroupingSymbol', '|')     # see CAVEATS in documentation
351     ->tag('Range', sub { $self
352       ->tag('From', $self->csv_headers ? 2 : 1)
353     })
354     ->tag('VariableLength', sub { $self
355       ->tag('ColumnDelimiter', ',')       # see CAVEATS for missing RecordDelimiter
356       ->tag('TextEncapsulator', '"')
357       ->datev_columns
358       ->datev_foreign_keys
359     })
360   });
361 }
362
363 sub datev_columns {
364   my ($self, $table) = @_;
365
366   my %cols_by_primary_key = partition_by { 1 * $datev_column_defs{$_}{primary_key} } @datev_columns;
367
368   for my $column (@{ $cols_by_primary_key{1} }) {
369     my $type = $column_types{ $datev_column_defs{$column}{type} };
370
371     die "unknown col type @{[ $column ]}" unless $type;
372
373     $self->tag('VariablePrimaryKey', sub { $self
374       ->tag('Name', $datev_column_defs{$column}{text});
375       $type->($self);
376     })
377   }
378
379   for my $column (@{ $cols_by_primary_key{0} }) {
380     my $type = $column_types{ $datev_column_defs{$column}{type} };
381
382     die "unknown col type @{[ ref $column]}" unless $type;
383
384     $self->tag('VariableColumn', sub { $self
385       ->tag('Name', $datev_column_defs{$column}{text});
386       $type->($self);
387     })
388   }
389
390   $self;
391 }
392
393 sub datev_foreign_keys {
394   my ($self) = @_;
395   # hard code weeee
396   $self->tag('ForeignKey', sub { $_[0]
397     ->tag('Name', $datev_column_defs{customer_id}{text})
398     ->tag('References', 'customer')
399   });
400   $self->tag('ForeignKey', sub { $_[0]
401     ->tag('Name', $datev_column_defs{vendor_id}{text})
402     ->tag('References', 'vendor')
403   });
404   $self->tag('ForeignKey', sub { $_[0]
405     ->tag('Name', $datev_column_defs{$_}{text})
406     ->tag('References', 'chart')
407   }) for qw(debit_accno credit_accno tax_accno);
408 }
409
410 sub do_datev_csv_export {
411   my ($self) = @_;
412
413   my $datev = SL::DATEV->new(from => $self->from, to => $self->to);
414
415   $datev->_get_transactions(from_to => $datev->fromto);
416
417   for my $transaction (@{ $datev->{DATEV} }) {
418     for my $entry (@{ $transaction }) {
419       $entry->{sortkey} = join '-', map { lc } (DateTime->from_kivitendo($entry->{transdate})->strftime('%Y%m%d'), $entry->{name}, $entry->{reference});
420     }
421   }
422
423   my @transactions = sort_by { $_->[0]->{sortkey} } @{ $datev->{DATEV} };
424
425   my $csv = Text::CSV_XS->new({ binary => 1, eol => "\r\n", sep_char => ",", quote_char => '"' });
426
427   my ($fh, $filename) = File::Temp::tempfile();
428   binmode($fh, ':utf8');
429
430   $self->files->{"transactions.csv"} = $filename;
431   push @{ $self->tempfiles }, $filename;
432
433   if ($self->csv_headers) {
434     $csv->print($fh, [ map { _normalize_cell($datev_column_defs{$_}{text}) } @datev_columns ]);
435   }
436
437   for my $transaction (@transactions) {
438     my $is_payment     = any { $_->{link} =~ m{A[PR]_paid} } @{ $transaction };
439
440     my ($soll, $haben) = map { $transaction->[$_] } ($transaction->[0]->{amount} > 0 ? (1, 0) : (0, 1));
441     my $tax            = defined($soll->{tax_amount}) ? $soll : defined($haben->{tax_amount}) ? $haben : {};
442     my $amount         = defined($soll->{net_amount}) ? $soll : $haben;
443     $haben->{notes}    = ($haben->{memo} || $soll->{memo}) if $haben->{memo} || $soll->{memo};
444     $haben->{notes}  //= '';
445     $haben->{notes}    =  SL::HTML::Util->strip($haben->{notes});
446
447     my %row            = (
448       amount           => $::form->format_amount($myconfig, abs($amount->{amount}),5),
449       debit_accno      => $soll->{accno},
450       debit_accname    => $soll->{accname},
451       credit_accno     => $haben->{accno},
452       credit_accname   => $haben->{accname},
453       tax              => defined $amount->{net_amount} ? $::form->format_amount($myconfig, abs($amount->{amount}) - abs($amount->{net_amount}), 5) : 0,
454       notes            => $haben->{notes},
455       (map { ($_ => $tax->{$_})                    } qw(taxkey tax_accname tax_accno taxdescription)),
456       (map { ($_ => ($haben->{$_} // $soll->{$_})) } qw(trans_id invnumber name vcnumber transdate itime customer_id vendor_id)),
457     );
458
459     _normalize_cell($_) for values %row; # see CAVEATS
460
461     $csv->print($fh, [ map { $row{$_} } @datev_columns ]);
462   }
463
464   # and build xml spec for it
465 }
466
467 sub do_csv_export {
468   my ($self, $table) = @_;
469
470   my $csv = Text::CSV_XS->new({ binary => 1, eol => "\r\n", sep_char => ",", quote_char => '"' });
471
472   my ($fh, $filename) = File::Temp::tempfile();
473   binmode($fh, ':utf8');
474
475   $self->files->{"$table.csv"} = $filename;
476   push @{ $self->tempfiles }, $filename;
477
478   # in the right order (primary keys first)
479   my %cols_by_primary_key = _table_columns($table);
480   my @columns = (@{ $cols_by_primary_key{1} }, @{ $cols_by_primary_key{0} });
481   my %col_index = do { my $i = 0; map {; "$_" => $i++ } @columns };
482
483   if ($self->csv_headers) {
484     $csv->print($fh, [ map { _normalize_cell($column_titles{$table}{$_->name}) } @columns ]) or die $csv->error_diag;
485   }
486
487   # and normalize date stuff
488   my @select_tokens = map { (ref $_) =~ /Time/ ? $_->name . '::date' : $_->name } @columns;
489
490   my @where_tokens;
491   my @values;
492   if ($known_tables{$table}{transdate}) {
493     if ($self->from) {
494       push @where_tokens, "$known_tables{$table}{transdate} >= ?";
495       push @values, $self->from;
496     }
497     if ($self->to) {
498       push @where_tokens, "$known_tables{$table}{transdate} <= ?";
499       push @values, $self->to;
500     }
501   }
502   if ($known_tables{$table}{tables}) {
503     my ($col, @col_specs) = @{ $known_tables{$table}{tables} };
504     my %ids;
505     for (@col_specs) {
506       my ($ftable, $fkey) = split /\./, $_;
507       if (!exists $self->export_ids->{$ftable}{$fkey}) {
508          # check if we forgot to keep it
509          if (!grep { $_ eq $fkey } @{ $known_tables{$ftable}{keep} || [] }) {
510            die "unknown table spec '$_' for table $table, did you forget to keep $fkey in $ftable?"
511          } else {
512            # hmm, most likely just an empty set.
513            $self->export_ids->{$ftable}{$fkey} = {};
514          }
515       }
516       $ids{$_}++ for keys %{ $self->export_ids->{$ftable}{$fkey} };
517     }
518     if (keys %ids) {
519       push @where_tokens, "$col IN (@{[ join ',', ('?') x keys %ids ]})";
520       push @values, keys %ids;
521     } else {
522       push @where_tokens, '1=0';
523     }
524   }
525
526   my $where_clause = @where_tokens ? 'WHERE ' . join ' AND ', @where_tokens : '';
527
528   my $query = "SELECT " . join(', ', @select_tokens) . " FROM $table $where_clause";
529
530   my $sth = $::form->get_standard_dbh->prepare($query);
531   $sth->execute(@values) or die "error executing query $query: " . $sth->errstr;
532
533   while (my $row = $sth->fetch) {
534     for my $keep_col (@{ $known_tables{$table}{keep} || [] }) {
535       next if !$row->[$col_index{$keep_col}];
536       $self->export_ids->{$table}{$keep_col} ||= {};
537       $self->export_ids->{$table}{$keep_col}{$row->[$col_index{$keep_col}]}++;
538     }
539     _normalize_cell($_) for @$row; # see CAVEATS
540
541     $csv->print($fh, $row) or $csv->error_diag;
542   }
543   $sth->finish();
544 }
545
546 sub tag {
547   my ($self, $tag, $content) = @_;
548
549   $self->writer->startTag($tag);
550   if ('CODE' eq ref $content) {
551     $content->($self);
552   } else {
553     $self->writer->characters($content);
554   }
555   $self->writer->endTag;
556   return $self;
557 }
558
559 sub make_comment {
560   my $gobd_version  = API_VERSION();
561   my $kivi_version  = $::form->read_version;
562   my $person        = $::myconfig{name};
563   my $contact       = join ', ',
564     (t8("Email") . ": $::myconfig{email}" ) x!! $::myconfig{email},
565     (t8("Tel")   . ": $::myconfig{tel}" )   x!! $::myconfig{tel},
566     (t8("Fax")   . ": $::myconfig{fax}" )   x!! $::myconfig{fax};
567
568   t8('DataSet for GoBD version #1. Created with kivitendo #2 by #3 (#4)',
569     $gobd_version, $kivi_version, $person, $contact
570   );
571 }
572
573 sub client_name {
574   $_[0]->company
575 }
576
577 sub client_location {
578   $_[0]->location
579 }
580
581 sub sorted_tables {
582   my ($self) = @_;
583
584   my %given = map { $_ => 1 } @{ $self->tables };
585
586   grep { $given{$_} } @export_table_order;
587 }
588
589 sub all_tables {
590   my ($self, $yesno) = @_;
591
592   $self->tables(\@export_table_order) if $yesno;
593 }
594
595 sub _normalize_cell {
596   $_[0] =~ s/\r\n/ /g;
597   $_[0] =~ s/,/;/g;
598   $_[0]
599 }
600
601 sub init_files { +{} }
602 sub init_export_ids { +{} }
603 sub init_tempfiles { [] }
604 sub init_tables { [ grep { $known_tables{$_} } @export_table_order ] }
605 sub init_csv_headers { 1 }
606
607 sub API_VERSION {
608   DateTime->new(year => 2002, month => 8, day => 14)->to_kivitendo;
609 }
610
611 sub DESTROY {
612   unlink $_ for @{ $_[0]->tempfiles || [] };
613 }
614
615 1;
616
617 __END__
618
619 =encoding utf-8
620
621 =head1 NAME
622
623 SL::GoBD - IDEA export generator
624
625 =head1 FUNCTIONS
626
627 =over 4
628
629 =item C<new PARAMS>
630
631 Create new export object. C<PARAMS> may contain:
632
633 =over 4
634
635 =item company
636
637 The name of the company, needed for the supplier header
638
639 =item location
640
641 Location of the company, needed for the supplier header
642
643 =item from
644
645 =item to
646
647 Will only include records in the specified date range. Data pulled from other
648 tables will be culled to match what is needed for these records.
649
650 =item csv_headers
651
652 Optional. If set, will include a header line in the exported CSV files. Default true.
653
654 =item tables
655
656 Ooptional list of tables to be exported. Defaults to all tables.
657
658 =item all_tables
659
660 Optional alternative to C<tables>, forces all known tables.
661
662 =back
663
664 =item C<generate_export>
665
666 Do the work. Will return an absolute path to a temp file where all export files
667 are zipped together.
668
669 =back
670
671 =head1 CAVEATS
672
673 Sigh. There are a lot of issues with the IDEA software that were found out by
674 trial and error.
675
676 =head2 Problems in the Specification
677
678 =over 4
679
680 =item *
681
682 The specced date format is capable of only C<YY>, C<YYYY>, C<MM>,
683 and C<DD>. There are no timestamps or timezones.
684
685 =item *
686
687 Numbers have the same issue. There is not dedicated integer type, and hinting
688 at an integer type by setting accuracy to 0 generates a warning for redundant
689 accuracy.
690
691 Also the number parsing is documented to be fragile. Official docs state that
692 behaviour for too low C<Accuracy> settings is undefined.
693
694 =item *
695
696 Foreign key definition is broken. Instead of giving column maps it assumes that
697 foreign keys map to the primary keys given for the target table, and in that
698 order. Also the target table must be known in full before defining a foreign key.
699
700 As a consequence any additional keys apart from primary keys are not possible.
701 Self-referencing tables are also not possible.
702
703 =item *
704
705 The spec does not support splitting data sets into smaller chunks. For data
706 sets that exceed 700MB the spec helpfully suggests: "Use a bigger medium, such
707 as a DVD".
708
709 =item *
710
711 It is not possible to set an empty C<DigitGroupingSymbol> since then the import
712 will just work with the default. This was asked in their forum, and the
713 response actually was to use a bogus grouping symbol that is not used:
714
715   Einfache Lösung: Definieren Sie das Tausendertrennzeichen als Komma, auch
716   wenn es nicht verwendet wird. Sollten Sie das Komma bereits als Feldtrenner
717   verwenden, so wählen Sie als Tausendertrennzeichen eine Alternative wie das
718   Pipe-Symbol |.
719
720 L<http://www.gdpdu-portal.com/forum/index.php?mode=thread&id=1392>
721
722 =item *
723
724 It is not possible to define a C<RecordDelimiter> with XML entities. &#x0A;
725 generates the error message:
726
727   C<RecordDelimiter>-Wert (&#x0A;) sollte immer aus ein oder zwei Zeichen
728   bestehen.
729
730 Instead we just use the implicit default RecordDelimiter CRLF.
731
732 =back
733
734 =head2 Bugs in the IDEA software
735
736 =over 4
737
738 =item *
739
740 The CSV import library used in IDEA is not able to parse newlines (or more
741 exactly RecordDelimiter) in data. So this export substites all of these with
742 spaces.
743
744 =item *
745
746 Neither it is able to parse escaped C<ColumnDelimiter> in data. It just splits
747 on that symbol no matter what surrounds or preceeds it.
748
749 =item *
750
751 Oh and of course C<TextEncapsulator> is also not allowed in data. It's just
752 stripped at the beginning and end of data.
753
754 =item *
755
756 And the character "!" is used internally as a warning signal and must not be
757 present in the data as well.
758
759 =item *
760
761 C<VariableLength> data is truncated on import to 512 bytes (Note: it said
762 characters, but since they are mutilating data into a single byte encoding
763 anyway, they most likely meant bytes). The auditor recommends splitting into
764 multiple columns.
765
766 =item *
767
768 Despite the standard specifying UTF-8 as a valid encoding the IDEA software
769 will just downgrade everything to latin1.
770
771 =back
772
773 =head2 Problems outside of the software
774
775 =over 4
776
777 =item *
778
779 The law states that "all business related data" should be made available. In
780 practice there's no definition for what makes data "business related", and
781 different auditors seems to want different data.
782
783 Currently we export most of the transactional data with supplementing
784 customers, vendors and chart of accounts.
785
786 =item *
787
788 While the standard explicitely state to provide data normalized, in practice
789 autditors aren't trained database operators and can not create complex vies on
790 normalized data on their own. The reason this works for other software is, that
791 DATEV and SAP seem to have written import plugins for their internal formats in
792 the IDEA software.
793
794 So what is really exported is not unlike a DATEV export. Each transaction gets
795 splitted into chunks of 2 positions (3 with tax on one side). Those get
796 denormalized into a single data row with credfit/debit/tax fields. The charts
797 get denormalized into it as well, in addition to their account number serving
798 as a foreign key.
799
800 Customers and vendors get denormalized into this as well, but are linked by ids
801 to their tables. And the reason for this is...
802
803 =item *
804
805 Some auditors do not have a full license of the IDEA software, and
806 can't do table joins.
807
808 =back
809
810 =head1 AUTHOR
811
812 Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>
813
814 =cut