4 # optional: background jobable
 
   9 use parent qw(Rose::Object);
 
  16 use List::MoreUtils qw(any);
 
  17 use List::UtilsBy qw(partition_by sort_by);
 
  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);
 
  24 use Rose::Object::MakeMethods::Generic (
 
  25   scalar                  => [ qw(from to writer company location) ],
 
  26   'scalar --get_set_init' => [ qw(files tempfiles export_ids tables csv_headers) ],
 
  31 # name:        short name, translated
 
  32 # description: long description, translated
 
  33 # columns:     list of columns to export. export all columns if not present
 
  34 # primary_key: override primary key
 
  36   chart    => { name => t8('Charts'),    description => t8('Chart of Accounts'),    primary_key => 'accno', columns => [ qw(id accno description) ],     },
 
  37   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) ] },
 
  38   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) ] },
 
  44      accno          => t8('Account Number'),
 
  45      description    => t8('Description'),
 
  50      department_1   => t8('Department 1'),
 
  51      department_2   => t8('Department 2'),
 
  52      street         => t8('Street'),
 
  53      zipcode        => t8('Zipcode'),
 
  55      country        => t8('Country'),
 
  56      contact        => t8('Contact'),
 
  59      email          => t8('E-mail'),
 
  61      customernumber => t8('Customer Number'),
 
  62      vendornumber   => t8('Vendor Number'),
 
  63      taxnumber      => t8('Tax Number'),
 
  64      obsolete       => t8('Obsolete'),
 
  65      ustid          => t8('Tax ID number'),
 
  68 $column_titles{$_} = $column_titles{customer_vendor} for qw(customer vendor);
 
  70 my %datev_column_defs = (
 
  71   trans_id          => { type => 'Rose::DB::Object::Metadata::Column::Integer', text => t8('ID'), },
 
  72   amount            => { type => 'Rose::DB::Object::Metadata::Column::Numeric', text => t8('Amount'), },
 
  73   credit_accname    => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Credit Account Name'), },
 
  74   credit_accno      => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Credit Account'), },
 
  75   credit_amount     => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Credit Amount'), },
 
  76   credit_tax        => { type => 'Rose::DB::Object::Metadata::Column::Numeric', text => t8('Credit Tax (lit)'), },
 
  77   debit_accname     => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Debit Account Name'), },
 
  78   debit_accno       => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Debit Account'), },
 
  79   debit_amount      => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Debit Amount'), },
 
  80   debit_tax         => { type => 'Rose::DB::Object::Metadata::Column::Numeric', text => t8('Debit Tax (lit)'), },
 
  81   invnumber         => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Reference'), },
 
  82   name              => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Name'), },
 
  83   notes             => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Notes'), },
 
  84   tax               => { type => 'Rose::DB::Object::Metadata::Column::Numeric', text => t8('Tax'), },
 
  85   taxdescription    => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('tax_taxdescription'), },
 
  86   taxkey            => { type => 'Rose::DB::Object::Metadata::Column::Integer', text => t8('Taxkey'), },
 
  87   tax_accname       => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Tax Account Name'), },
 
  88   tax_accno         => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Tax Account'), },
 
  89   transdate         => { type => 'Rose::DB::Object::Metadata::Column::Date',    text => t8('Transdate'), },
 
  90   vcnumber          => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Customer/Vendor Number'), },
 
  91   customer_id       => { type => 'Rose::DB::Object::Metadata::Column::Integer', text => t8('Customer (database ID)'), },
 
  92   vendor_id         => { type => 'Rose::DB::Object::Metadata::Column::Integer', text => t8('Vendor (database ID)'), },
 
  93   itime             => { type => 'Rose::DB::Object::Metadata::Column::Date',    text => t8('Create Date'), },
 
  94   gldate            => { type => 'Rose::DB::Object::Metadata::Column::Date',    text => t8('Gldate'), },
 
  97 my @datev_columns = qw(
 
 101   transdate    invnumber      amount
 
 102   debit_accno  debit_accname debit_amount debit_tax
 
 103   credit_accno credit_accname credit_amount credit_tax
 
 105   tax_accno    tax_accname    taxkey
 
 109 # rows in this listing are tiers.
 
 110 # tables may depend on ids in a tier above them
 
 111 my @export_table_order = qw(
 
 112   ar ap gl oe delivery_orders
 
 113   invoice orderitems delivery_order_items
 
 120 # needed because the standard dbh sets datestyle german and we don't want to mess with that
 
 121 my $date_format = 'DD.MM.YYYY';
 
 122 my $number_format = '1000.00';
 
 124 my $myconfig = { numberformat => $number_format };
 
 126 # callbacks that produce the xml spec for these column types
 
 128   'Rose::DB::Object::Metadata::Column::Integer'   => sub { $_[0]->tag('Numeric') },  # see Caveats for integer issues
 
 129   'Rose::DB::Object::Metadata::Column::BigInt'    => sub { $_[0]->tag('Numeric') },  # see Caveats for integer issues
 
 130   'Rose::DB::Object::Metadata::Column::Text'      => sub { $_[0]->tag('AlphaNumeric') },
 
 131   'Rose::DB::Object::Metadata::Column::Varchar'   => sub { $_[0]->tag('AlphaNumeric') },
 
 132   'Rose::DB::Object::Metadata::Column::Character' => sub { $_[0]->tag('AlphaNumeric') },
 
 133   'Rose::DB::Object::Metadata::Column::Numeric'   => sub { $_[0]->tag('Numeric', sub { $_[0]->tag('Accuracy', 5) }) },
 
 134   'Rose::DB::Object::Metadata::Column::Date'      => sub { $_[0]->tag('Date', sub { $_[0]->tag('Format', $date_format) }) },
 
 135   'Rose::DB::Object::Metadata::Column::Timestamp' => sub { $_[0]->tag('Date', sub { $_[0]->tag('Format', $date_format) }) },
 
 136   'Rose::DB::Object::Metadata::Column::Float'     => sub { $_[0]->tag('Numeric') },
 
 137   'Rose::DB::Object::Metadata::Column::Boolean'   => sub { $_[0]
 
 138     ->tag('AlphaNumeric')
 
 139     ->tag('Map', sub { $_[0]
 
 141       ->tag('To', t8('true'))
 
 143     ->tag('Map', sub { $_[0]
 
 145       ->tag('To', t8('false'))
 
 147     ->tag('Map', sub { $_[0]
 
 149       ->tag('To', t8('false'))
 
 154 sub generate_export {
 
 158   $self->from && 'DateTime' eq ref $self->from or die 'need from date';
 
 159   $self->to   && 'DateTime' eq ref $self->to   or die 'need to date';
 
 160   $self->from <= $self->to                     or die 'from date must be earlier or equal than to date';
 
 161   $self->tables && @{ $self->tables }          or die 'need tables';
 
 162   for (@{ $self->tables }) {
 
 163     next if $known_tables{$_};
 
 164     die "unknown table '$_'";
 
 167   # get data from those tables and save to csv
 
 168   # for that we need to build queries that fetch all the columns
 
 169   for ($self->sorted_tables) {
 
 170     $self->do_csv_export($_);
 
 173   $self->do_datev_csv_export;
 
 179   $self->files->{'gdpdu-01-08-2002.dtd'} = File::Spec->catfile('users', 'gdpdu-01-08-2002.dtd');
 
 182   my ($fh, $zipfile) = File::Temp::tempfile();
 
 183   my $zip            = Archive::Zip->new;
 
 185   while (my ($name, $file) = each %{ $self->files }) {
 
 186     $zip->addFile($file, $name);
 
 189   $zip->writeToFileHandle($fh) == Archive::Zip::AZ_OK() or die 'error writing zip file';
 
 198   my ($fh, $filename) = File::Temp::tempfile();
 
 199   binmode($fh, ':utf8');
 
 201   $self->files->{'INDEX.XML'} = $filename;
 
 202   push @{ $self->tempfiles }, $filename;
 
 204   my $writer = XML::Writer->new(
 
 209   $self->writer($writer);
 
 210   $self->writer->xmlDecl('UTF-8');
 
 211   $self->writer->doctype('DataSet', undef, "gdpdu-01-08-2002.dtd");
 
 212   $self->tag('DataSet', sub { $self
 
 213     ->tag('Version', '1.0')
 
 214     ->tag('DataSupplier', sub { $self
 
 215       ->tag('Name', $self->client_name)
 
 216       ->tag('Location', $self->client_location)
 
 217       ->tag('Comment', $self->make_comment)
 
 219     ->tag('Media', sub { $self
 
 220       ->tag('Name', t8('DataSet #1', 1));
 
 221       for (reverse $self->sorted_tables) { $self  # see CAVEATS for table order
 
 224       $self->do_datev_xml_table;
 
 231   my ($self, $table) = @_;
 
 232   my $writer = $self->writer;
 
 234   $self->tag('Table', sub { $self
 
 235     ->tag('URL', "$table.csv")
 
 236     ->tag('Name', $known_tables{$table}{name})
 
 237     ->tag('Description', $known_tables{$table}{description})
 
 238     ->tag('Validity', sub { $self
 
 239       ->tag('Range', sub { $self
 
 240         ->tag('From', $self->from->to_kivitendo(dateformat => 'dd.mm.yyyy'))
 
 241         ->tag('To',   $self->to->to_kivitendo(dateformat => 'dd.mm.yyyy'))
 
 243       ->tag('Format', $date_format)
 
 246     ->tag('DecimalSymbol', '.')
 
 247     ->tag('DigitGroupingSymbol', '|')     # see CAVEATS in documentation
 
 248     ->tag('Range', sub { $self
 
 249       ->tag('From', $self->csv_headers ? 2 : 1)
 
 251     ->tag('VariableLength', sub { $self
 
 252       ->tag('ColumnDelimiter', ',')       # see CAVEATS for missing RecordDelimiter
 
 253       ->tag('TextEncapsulator', '"')
 
 255       ->foreign_keys($table)
 
 262   my $package = SL::DB::Helper::Mappings::get_package_for_table($table);
 
 265   my $use_white_list = 0;
 
 266   if ($known_tables{$table}{columns}) {
 
 268     $white_list{$_} = 1 for @{ $known_tables{$table}{columns} || [] };
 
 271   # PrimaryKeys must come before regular columns, so partition first
 
 273     $known_tables{$table}{primary_key}
 
 274       ? 1 * ($_ eq $known_tables{$table}{primary_key})
 
 275       : 1 * $_->is_primary_key_member
 
 277     $use_white_list ? $white_list{$_->name} : 1
 
 278   } $package->meta->columns;
 
 282   my ($self, $table) = @_;
 
 284   my %cols_by_primary_key = _table_columns($table);
 
 286   for my $column (@{ $cols_by_primary_key{1} }) {
 
 287     my $type = $column_types{ ref $column };
 
 289     die "unknown col type @{[ ref $column ]}" unless $type;
 
 291     $self->tag('VariablePrimaryKey', sub { $self
 
 292       ->tag('Name', $column_titles{$table}{$column->name});
 
 297   for my $column (@{ $cols_by_primary_key{0} }) {
 
 298     my $type = $column_types{ ref $column };
 
 300     die "unknown col type @{[ ref $column]}" unless $type;
 
 302     $self->tag('VariableColumn', sub { $self
 
 303       ->tag('Name', $column_titles{$table}{$column->name});
 
 312   my ($self, $table) = @_;
 
 313   my $package = SL::DB::Helper::Mappings::get_package_for_table($table);
 
 315   my %requested = map { $_ => 1 } @{ $self->tables };
 
 317   for my $rel ($package->meta->foreign_keys) {
 
 318     next unless $requested{ $rel->class->meta->table };
 
 320     # ok, now extract the columns used as foreign key
 
 321     my %key_columns = $rel->key_columns;
 
 323     if (1 != keys %key_columns) {
 
 324       die "multi keys? we don't support this currently. fix it please";
 
 327     if ($table eq $rel->class->meta->table) {
 
 328       # self referential foreign keys are a PITA to export correctly. skip!
 
 332     $self->tag('ForeignKey', sub {
 
 333       $_[0]->tag('Name',  $column_titles{$table}{$_}) for keys %key_columns;
 
 334       $_[0]->tag('References', $rel->class->meta->table);
 
 339 sub do_datev_xml_table {
 
 341   my $writer = $self->writer;
 
 343   $self->tag('Table', sub { $self
 
 344     ->tag('URL', "transactions.csv")
 
 345     ->tag('Name', t8('Transactions'))
 
 346     ->tag('Description', t8('Transactions'))
 
 347     ->tag('Validity', sub { $self
 
 348       ->tag('Range', sub { $self
 
 349         ->tag('From', $self->from->to_kivitendo(dateformat => 'dd.mm.yyyy'))
 
 350         ->tag('To',   $self->to->to_kivitendo(dateformat => 'dd.mm.yyyy'))
 
 352       ->tag('Format', $date_format)
 
 355     ->tag('DecimalSymbol', '.')
 
 356     ->tag('DigitGroupingSymbol', '|')     # see CAVEATS in documentation
 
 357     ->tag('Range', sub { $self
 
 358       ->tag('From', $self->csv_headers ? 2 : 1)
 
 360     ->tag('VariableLength', sub { $self
 
 361       ->tag('ColumnDelimiter', ',')       # see CAVEATS for missing RecordDelimiter
 
 362       ->tag('TextEncapsulator', '"')
 
 370   my ($self, $table) = @_;
 
 372   my %cols_by_primary_key = partition_by { 1 * $datev_column_defs{$_}{primary_key} } @datev_columns;
 
 374   for my $column (@{ $cols_by_primary_key{1} }) {
 
 375     my $type = $column_types{ $datev_column_defs{$column}{type} };
 
 377     die "unknown col type @{[ $column ]}" unless $type;
 
 379     $self->tag('VariablePrimaryKey', sub { $self
 
 380       ->tag('Name', $datev_column_defs{$column}{text});
 
 385   for my $column (@{ $cols_by_primary_key{0} }) {
 
 386     my $type = $column_types{ $datev_column_defs{$column}{type} };
 
 388     die "unknown col type @{[ ref $column]}" unless $type;
 
 390     $self->tag('VariableColumn', sub { $self
 
 391       ->tag('Name', $datev_column_defs{$column}{text});
 
 399 sub datev_foreign_keys {
 
 402   $self->tag('ForeignKey', sub { $_[0]
 
 403     ->tag('Name', $datev_column_defs{customer_id}{text})
 
 404     ->tag('References', 'customer')
 
 406   $self->tag('ForeignKey', sub { $_[0]
 
 407     ->tag('Name', $datev_column_defs{vendor_id}{text})
 
 408     ->tag('References', 'vendor')
 
 410   $self->tag('ForeignKey', sub { $_[0]
 
 411     ->tag('Name', $datev_column_defs{$_}{text})
 
 412     ->tag('References', 'chart')
 
 413   }) for qw(debit_accno credit_accno tax_accno);
 
 416 sub do_datev_csv_export {
 
 419   my $datev = SL::DATEV->new(from => $self->from, to => $self->to);
 
 421   $datev->generate_datev_data(from_to => $datev->fromto);
 
 423   if ($datev->errors) {
 
 424     die [ $datev->errors ];
 
 427   for my $transaction (@{ $datev->{DATEV} }) {
 
 428     for my $entry (@{ $transaction }) {
 
 429       $entry->{sortkey} = join '-', map { lc } (DateTime->from_kivitendo($entry->{transdate})->strftime('%Y%m%d'), $entry->{name}, $entry->{reference});
 
 433   my @transactions = sort_by { $_->[0]->{sortkey} } @{ $datev->{DATEV} };
 
 435   my $csv = Text::CSV_XS->new({ binary => 1, eol => "\r\n", sep_char => ",", quote_char => '"' });
 
 437   my ($fh, $filename) = File::Temp::tempfile();
 
 438   binmode($fh, ':utf8');
 
 440   $self->files->{"transactions.csv"} = $filename;
 
 441   push @{ $self->tempfiles }, $filename;
 
 443   if ($self->csv_headers) {
 
 444     $csv->print($fh, [ map { _normalize_cell($datev_column_defs{$_}{text}) } @datev_columns ]);
 
 447   for my $transaction (@transactions) {
 
 448     my $is_payment     = any { $_->{link} =~ m{A[PR]_paid} } @{ $transaction };
 
 450     my ($soll, $haben) = map { $transaction->[$_] } ($transaction->[0]->{amount} > 0 ? (1, 0) : (0, 1));
 
 451     my $tax            = defined($soll->{tax_accno}) ? $soll : defined($haben->{tax_accno}) ? $haben : {};
 
 452     my $amount         = defined($soll->{net_amount}) ? $soll : $haben;
 
 453     $haben->{notes}    = ($haben->{memo} || $soll->{memo}) if $haben->{memo} || $soll->{memo};
 
 454     $haben->{notes}  //= '';
 
 455     $haben->{notes}    =  SL::HTML::Util->strip($haben->{notes});
 
 457     my $tax_amount = defined $amount->{net_amount} ? abs($amount->{amount}) - abs($amount->{net_amount}) : 0;
 
 459     $tax = {} if abs($tax_amount) < 0.001;
 
 462       amount           => $::form->format_amount($myconfig, abs($amount->{amount}),5),
 
 463       debit_accno      => $soll->{accno},
 
 464       debit_accname    => $soll->{accname},
 
 465       debit_amount     => $::form->format_amount($myconfig, abs(-$soll->{amount}),5),
 
 466       debit_tax        => $soll->{tax_accno} ? $::form->format_amount($myconfig, $tax_amount, 5) : 0,
 
 467       credit_accno     => $haben->{accno},
 
 468       credit_accname   => $haben->{accname},
 
 469       credit_amount    => $::form->format_amount($myconfig, abs($haben->{amount}),5),,
 
 470       credit_tax       => $haben->{tax_accno} ? $::form->format_amount($myconfig, $tax_amount, 5) : 0,
 
 471       tax              => $::form->format_amount($myconfig, $tax_amount, 5),
 
 472       notes            => $haben->{notes},
 
 473       (map { ($_ => $tax->{$_})                    } qw(taxkey tax_accname tax_accno taxdescription)),
 
 474       (map { ($_ => ($haben->{$_} // $soll->{$_})) } qw(trans_id invnumber name vcnumber transdate gldate itime customer_id vendor_id)),
 
 477 #     if ($row{debit_amount} + $row{debit_tax} - ($row{credit_amount} + $row{credit_tax}) > 0.005) {
 
 478 #       $::lxdebug->dump(0,  "broken taxes", [ $transaction, \%row,  $row{debit_amount} + $row{debit_tax}, $row{credit_amount} + $row{credit_tax} ]);
 
 481     _normalize_cell($_) for values %row; # see CAVEATS
 
 483     $csv->print($fh, [ map { $row{$_} } @datev_columns ]);
 
 486   # and build xml spec for it
 
 490   my ($self, $table) = @_;
 
 492   my $csv = Text::CSV_XS->new({ binary => 1, eol => "\r\n", sep_char => ",", quote_char => '"' });
 
 494   my ($fh, $filename) = File::Temp::tempfile();
 
 495   binmode($fh, ':utf8');
 
 497   $self->files->{"$table.csv"} = $filename;
 
 498   push @{ $self->tempfiles }, $filename;
 
 500   # in the right order (primary keys first)
 
 501   my %cols_by_primary_key = _table_columns($table);
 
 502   my @columns = (@{ $cols_by_primary_key{1} }, @{ $cols_by_primary_key{0} });
 
 503   my %col_index = do { my $i = 0; map {; "$_" => $i++ } @columns };
 
 505   if ($self->csv_headers) {
 
 506     $csv->print($fh, [ map { _normalize_cell($column_titles{$table}{$_->name}) } @columns ]) or die $csv->error_diag;
 
 509   # and normalize date stuff
 
 510   my @select_tokens = map { (ref $_) =~ /Time/ ? $_->name . '::date' : $_->name } @columns;
 
 514   if ($known_tables{$table}{transdate}) {
 
 516       push @where_tokens, "$known_tables{$table}{transdate} >= ?";
 
 517       push @values, $self->from;
 
 520       push @where_tokens, "$known_tables{$table}{transdate} <= ?";
 
 521       push @values, $self->to;
 
 524   if ($known_tables{$table}{tables}) {
 
 525     my ($col, @col_specs) = @{ $known_tables{$table}{tables} };
 
 528       my ($ftable, $fkey) = split /\./, $_;
 
 529       if (!exists $self->export_ids->{$ftable}{$fkey}) {
 
 530          # check if we forgot to keep it
 
 531          if (!grep { $_ eq $fkey } @{ $known_tables{$ftable}{keep} || [] }) {
 
 532            die "unknown table spec '$_' for table $table, did you forget to keep $fkey in $ftable?"
 
 534            # hmm, most likely just an empty set.
 
 535            $self->export_ids->{$ftable}{$fkey} = {};
 
 538       $ids{$_}++ for keys %{ $self->export_ids->{$ftable}{$fkey} };
 
 541       push @where_tokens, "$col IN (@{[ join ',', ('?') x keys %ids ]})";
 
 542       push @values, keys %ids;
 
 544       push @where_tokens, '1=0';
 
 548   my $where_clause = @where_tokens ? 'WHERE ' . join ' AND ', @where_tokens : '';
 
 550   my $query = "SELECT " . join(', ', @select_tokens) . " FROM $table $where_clause";
 
 552   my $sth = $::form->get_standard_dbh->prepare($query);
 
 553   $sth->execute(@values) or $::form->dberror($query);
 
 555   while (my $row = $sth->fetch) {
 
 556     for my $keep_col (@{ $known_tables{$table}{keep} || [] }) {
 
 557       next if !$row->[$col_index{$keep_col}];
 
 558       $self->export_ids->{$table}{$keep_col} ||= {};
 
 559       $self->export_ids->{$table}{$keep_col}{$row->[$col_index{$keep_col}]}++;
 
 561     _normalize_cell($_) for @$row; # see CAVEATS
 
 563     $csv->print($fh, $row) or $csv->error_diag;
 
 569   my ($self, $tag, $content) = @_;
 
 571   $self->writer->startTag($tag);
 
 572   if ('CODE' eq ref $content) {
 
 575     $self->writer->characters($content);
 
 577   $self->writer->endTag;
 
 582   my $gobd_version  = API_VERSION();
 
 583   my $kivi_version  = SL::Version->get_version;
 
 584   my $person        = $::myconfig{name};
 
 585   my $contact       = join ', ',
 
 586     (t8("Email") . ": $::myconfig{email}" ) x!! $::myconfig{email},
 
 587     (t8("Tel")   . ": $::myconfig{tel}" )   x!! $::myconfig{tel},
 
 588     (t8("Fax")   . ": $::myconfig{fax}" )   x!! $::myconfig{fax};
 
 590   t8('DataSet for GoBD version #1. Created with kivitendo #2 by #3 (#4)',
 
 591     $gobd_version, $kivi_version, $person, $contact
 
 599 sub client_location {
 
 606   my %given = map { $_ => 1 } @{ $self->tables };
 
 608   grep { $given{$_} } @export_table_order;
 
 612   my ($self, $yesno) = @_;
 
 614   $self->tables(\@export_table_order) if $yesno;
 
 617 sub _normalize_cell {
 
 625 sub init_files { +{} }
 
 626 sub init_export_ids { +{} }
 
 627 sub init_tempfiles { [] }
 
 628 sub init_tables { [ grep { $known_tables{$_} } @export_table_order ] }
 
 629 sub init_csv_headers { 1 }
 
 632   DateTime->new(year => 2002, month => 8, day => 14)->to_kivitendo;
 
 636   unlink $_ for @{ $_[0]->tempfiles || [] };
 
 647 SL::GoBD - IDEA export generator
 
 655 Create new export object. C<PARAMS> may contain:
 
 661 The name of the company, needed for the supplier header
 
 665 Location of the company, needed for the supplier header
 
 671 Will only include records in the specified date range. Data pulled from other
 
 672 tables will be culled to match what is needed for these records.
 
 676 Optional. If set, will include a header line in the exported CSV files. Default true.
 
 680 Ooptional list of tables to be exported. Defaults to all tables.
 
 684 Optional alternative to C<tables>, forces all known tables.
 
 688 =item C<generate_export>
 
 690 Do the work. Will return an absolute path to a temp file where all export files
 
 697 Sigh. There are a lot of issues with the IDEA software that were found out by
 
 700 =head2 Problems in the Specification
 
 706 The specced date format is capable of only C<YY>, C<YYYY>, C<MM>,
 
 707 and C<DD>. There are no timestamps or timezones.
 
 711 Numbers have the same issue. There is not dedicated integer type, and hinting
 
 712 at an integer type by setting accuracy to 0 generates a warning for redundant
 
 715 Also the number parsing is documented to be fragile. Official docs state that
 
 716 behaviour for too low C<Accuracy> settings is undefined.
 
 720 Foreign key definition is broken. Instead of giving column maps it assumes that
 
 721 foreign keys map to the primary keys given for the target table, and in that
 
 722 order. Also the target table must be known in full before defining a foreign key.
 
 724 As a consequence any additional keys apart from primary keys are not possible.
 
 725 Self-referencing tables are also not possible.
 
 729 The spec does not support splitting data sets into smaller chunks. For data
 
 730 sets that exceed 700MB the spec helpfully suggests: "Use a bigger medium, such
 
 735 It is not possible to set an empty C<DigitGroupingSymbol> since then the import
 
 736 will just work with the default. This was asked in their forum, and the
 
 737 response actually was to use a bogus grouping symbol that is not used:
 
 739   Einfache Lösung: Definieren Sie das Tausendertrennzeichen als Komma, auch
 
 740   wenn es nicht verwendet wird. Sollten Sie das Komma bereits als Feldtrenner
 
 741   verwenden, so wählen Sie als Tausendertrennzeichen eine Alternative wie das
 
 744 L<http://www.gdpdu-portal.com/forum/index.php?mode=thread&id=1392>
 
 748 It is not possible to define a C<RecordDelimiter> with XML entities. 

 
 749 generates the error message:
 
 751   C<RecordDelimiter>-Wert (
) sollte immer aus ein oder zwei Zeichen
 
 754 Instead we just use the implicit default RecordDelimiter CRLF.
 
 758 =head2 Bugs in the IDEA software
 
 764 The CSV import library used in IDEA is not able to parse newlines (or more
 
 765 exactly RecordDelimiter) in data. So this export substites all of these with
 
 770 Neither it is able to parse escaped C<ColumnDelimiter> in data. It just splits
 
 771 on that symbol no matter what surrounds or preceeds it.
 
 775 Oh and of course C<TextEncapsulator> is also not allowed in data. It's just
 
 776 stripped at the beginning and end of data.
 
 780 And the character "!" is used internally as a warning signal and must not be
 
 781 present in the data as well.
 
 785 C<VariableLength> data is truncated on import to 512 bytes (Note: it said
 
 786 characters, but since they are mutilating data into a single byte encoding
 
 787 anyway, they most likely meant bytes). The auditor recommends splitting into
 
 792 Despite the standard specifying UTF-8 as a valid encoding the IDEA software
 
 793 will just downgrade everything to latin1.
 
 797 =head2 Problems outside of the software
 
 803 The law states that "all business related data" should be made available. In
 
 804 practice there's no definition for what makes data "business related", and
 
 805 different auditors seems to want different data.
 
 807 Currently we export most of the transactional data with supplementing
 
 808 customers, vendors and chart of accounts.
 
 812 While the standard explicitely state to provide data normalized, in practice
 
 813 autditors aren't trained database operators and can not create complex vies on
 
 814 normalized data on their own. The reason this works for other software is, that
 
 815 DATEV and SAP seem to have written import plugins for their internal formats in
 
 818 So what is really exported is not unlike a DATEV export. Each transaction gets
 
 819 splitted into chunks of 2 positions (3 with tax on one side). Those get
 
 820 denormalized into a single data row with credfit/debit/tax fields. The charts
 
 821 get denormalized into it as well, in addition to their account number serving
 
 824 Customers and vendors get denormalized into this as well, but are linked by ids
 
 825 to their tables. And the reason for this is...
 
 829 Some auditors do not have a full license of the IDEA software, and
 
 830 can't do table joins.
 
 836 Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>