GoBD: Steuerschlüssel richtig setzen
[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::Numeric', 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_accno}) ? $soll : defined($haben->{tax_accno}) ? $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] =~ s/"/'/g;
599   $_[0] =~ s/!/./g;
600   $_[0]
601 }
602
603 sub init_files { +{} }
604 sub init_export_ids { +{} }
605 sub init_tempfiles { [] }
606 sub init_tables { [ grep { $known_tables{$_} } @export_table_order ] }
607 sub init_csv_headers { 1 }
608
609 sub API_VERSION {
610   DateTime->new(year => 2002, month => 8, day => 14)->to_kivitendo;
611 }
612
613 sub DESTROY {
614   unlink $_ for @{ $_[0]->tempfiles || [] };
615 }
616
617 1;
618
619 __END__
620
621 =encoding utf-8
622
623 =head1 NAME
624
625 SL::GoBD - IDEA export generator
626
627 =head1 FUNCTIONS
628
629 =over 4
630
631 =item C<new PARAMS>
632
633 Create new export object. C<PARAMS> may contain:
634
635 =over 4
636
637 =item company
638
639 The name of the company, needed for the supplier header
640
641 =item location
642
643 Location of the company, needed for the supplier header
644
645 =item from
646
647 =item to
648
649 Will only include records in the specified date range. Data pulled from other
650 tables will be culled to match what is needed for these records.
651
652 =item csv_headers
653
654 Optional. If set, will include a header line in the exported CSV files. Default true.
655
656 =item tables
657
658 Ooptional list of tables to be exported. Defaults to all tables.
659
660 =item all_tables
661
662 Optional alternative to C<tables>, forces all known tables.
663
664 =back
665
666 =item C<generate_export>
667
668 Do the work. Will return an absolute path to a temp file where all export files
669 are zipped together.
670
671 =back
672
673 =head1 CAVEATS
674
675 Sigh. There are a lot of issues with the IDEA software that were found out by
676 trial and error.
677
678 =head2 Problems in the Specification
679
680 =over 4
681
682 =item *
683
684 The specced date format is capable of only C<YY>, C<YYYY>, C<MM>,
685 and C<DD>. There are no timestamps or timezones.
686
687 =item *
688
689 Numbers have the same issue. There is not dedicated integer type, and hinting
690 at an integer type by setting accuracy to 0 generates a warning for redundant
691 accuracy.
692
693 Also the number parsing is documented to be fragile. Official docs state that
694 behaviour for too low C<Accuracy> settings is undefined.
695
696 =item *
697
698 Foreign key definition is broken. Instead of giving column maps it assumes that
699 foreign keys map to the primary keys given for the target table, and in that
700 order. Also the target table must be known in full before defining a foreign key.
701
702 As a consequence any additional keys apart from primary keys are not possible.
703 Self-referencing tables are also not possible.
704
705 =item *
706
707 The spec does not support splitting data sets into smaller chunks. For data
708 sets that exceed 700MB the spec helpfully suggests: "Use a bigger medium, such
709 as a DVD".
710
711 =item *
712
713 It is not possible to set an empty C<DigitGroupingSymbol> since then the import
714 will just work with the default. This was asked in their forum, and the
715 response actually was to use a bogus grouping symbol that is not used:
716
717   Einfache Lösung: Definieren Sie das Tausendertrennzeichen als Komma, auch
718   wenn es nicht verwendet wird. Sollten Sie das Komma bereits als Feldtrenner
719   verwenden, so wählen Sie als Tausendertrennzeichen eine Alternative wie das
720   Pipe-Symbol |.
721
722 L<http://www.gdpdu-portal.com/forum/index.php?mode=thread&id=1392>
723
724 =item *
725
726 It is not possible to define a C<RecordDelimiter> with XML entities. &#x0A;
727 generates the error message:
728
729   C<RecordDelimiter>-Wert (&#x0A;) sollte immer aus ein oder zwei Zeichen
730   bestehen.
731
732 Instead we just use the implicit default RecordDelimiter CRLF.
733
734 =back
735
736 =head2 Bugs in the IDEA software
737
738 =over 4
739
740 =item *
741
742 The CSV import library used in IDEA is not able to parse newlines (or more
743 exactly RecordDelimiter) in data. So this export substites all of these with
744 spaces.
745
746 =item *
747
748 Neither it is able to parse escaped C<ColumnDelimiter> in data. It just splits
749 on that symbol no matter what surrounds or preceeds it.
750
751 =item *
752
753 Oh and of course C<TextEncapsulator> is also not allowed in data. It's just
754 stripped at the beginning and end of data.
755
756 =item *
757
758 And the character "!" is used internally as a warning signal and must not be
759 present in the data as well.
760
761 =item *
762
763 C<VariableLength> data is truncated on import to 512 bytes (Note: it said
764 characters, but since they are mutilating data into a single byte encoding
765 anyway, they most likely meant bytes). The auditor recommends splitting into
766 multiple columns.
767
768 =item *
769
770 Despite the standard specifying UTF-8 as a valid encoding the IDEA software
771 will just downgrade everything to latin1.
772
773 =back
774
775 =head2 Problems outside of the software
776
777 =over 4
778
779 =item *
780
781 The law states that "all business related data" should be made available. In
782 practice there's no definition for what makes data "business related", and
783 different auditors seems to want different data.
784
785 Currently we export most of the transactional data with supplementing
786 customers, vendors and chart of accounts.
787
788 =item *
789
790 While the standard explicitely state to provide data normalized, in practice
791 autditors aren't trained database operators and can not create complex vies on
792 normalized data on their own. The reason this works for other software is, that
793 DATEV and SAP seem to have written import plugins for their internal formats in
794 the IDEA software.
795
796 So what is really exported is not unlike a DATEV export. Each transaction gets
797 splitted into chunks of 2 positions (3 with tax on one side). Those get
798 denormalized into a single data row with credfit/debit/tax fields. The charts
799 get denormalized into it as well, in addition to their account number serving
800 as a foreign key.
801
802 Customers and vendors get denormalized into this as well, but are linked by ids
803 to their tables. And the reason for this is...
804
805 =item *
806
807 Some auditors do not have a full license of the IDEA software, and
808 can't do table joins.
809
810 =back
811
812 =head1 AUTHOR
813
814 Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>
815
816 =cut