GDPDU: datev csv formatierung
[kivitendo-erp.git] / SL / GDPDU.pm
1 package SL::GDPDU;
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) ],
26 );
27
28 # in this we find:
29 # key:         table name
30 # name:        short name, translated
31 # description: long description, translated
32 # transdate:   column used to filter from/to, empty if table is filtered otherwise
33 # keep:        arrayref of columns that should be saved for further referencing
34 # tables:      arrayref with one column and one or many table.column references that were kept earlier
35 my %known_tables = (
36   chart                 => { name => t8('Charts'),                  description => t8('Chart of Accounts'),       primary_key => 'accno'     },
37   customer              => { name => t8('Customers'),               description => t8('Customer Master Data'),                                    },
38   vendor                => { name => t8('Vendors'),                 description => t8('Vendor Master Data'),                                               },
39 );
40
41 my %datev_column_defs = (
42   acc_trans_id      => { type => 'Rose::DB::Object::Metadata::Column::Integer', text => t8('ID'), primary_key => 1 },
43   amount            => { type => 'Rose::DB::Object::Metadata::Column::Numeric', text => t8('Amount'), },
44   credit_accname    => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Credit Account Name'), },
45   credit_accno      => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Credit Account'), },
46   debit_accname     => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Debit Account Name'), },
47   debit_accno       => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Debit Account'), },
48   invnumber         => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Reference'), },
49   name              => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Name'), },
50   notes             => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Notes'), },
51   tax               => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Tax'), },
52   taxkey            => { type => 'Rose::DB::Object::Metadata::Column::Integer', text => t8('Taxkey'), },
53   tax_accname       => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Tax Account Name'), },
54   tax_accno         => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Tax Account'), },
55   transdate         => { type => 'Rose::DB::Object::Metadata::Column::Date',    text => t8('Invoice Date'), },
56   vcnumber          => { type => 'Rose::DB::Object::Metadata::Column::Text',    text => t8('Customer/Vendor Number'), },
57   customer_id       => { type => 'Rose::DB::Object::Metadata::Column::Integer', text => t8('Customer ID'), },
58   vendor_id         => { type => 'Rose::DB::Object::Metadata::Column::Integer', text => t8('Vendor ID'), },
59 );
60
61 my @datev_columns = qw(
62   acc_trans_id
63   customer_id vendor_id
64   name           vcnumber
65   transdate    invnumber      amount
66   debit_accno  debit_accname
67   credit_accno credit_accname
68   tax
69   tax_accno    tax_accname    taxkey
70   notes
71 );
72
73 # rows in this listing are tiers.
74 # tables may depend on ids in a tier above them
75 my @export_table_order = qw(
76   ar ap gl oe delivery_orders
77   invoice orderitems delivery_order_items
78   customer vendor
79   parts
80   acc_trans
81   chart
82 );
83
84 # needed because the standard dbh sets datestyle german and we don't want to mess with that
85 my $date_format = 'DD.MM.YYYY';
86
87 # callbacks that produce the xml spec for these column types
88 my %column_types = (
89   'Rose::DB::Object::Metadata::Column::Integer'   => sub { $_[0]->tag('Numeric') },  # see Caveats for integer issues
90   'Rose::DB::Object::Metadata::Column::BigInt'    => sub { $_[0]->tag('Numeric') },  # see Caveats for integer issues
91   'Rose::DB::Object::Metadata::Column::Text'      => sub { $_[0]->tag('AlphaNumeric') },
92   'Rose::DB::Object::Metadata::Column::Varchar'   => sub { $_[0]->tag('AlphaNumeric') },
93   'Rose::DB::Object::Metadata::Column::Character' => sub { $_[0]->tag('AlphaNumeric') },
94   'Rose::DB::Object::Metadata::Column::Numeric'   => sub { $_[0]->tag('Numeric', sub { $_[0]->tag('Accuracy', 5) }) },
95   'Rose::DB::Object::Metadata::Column::Date'      => sub { $_[0]->tag('Date', sub { $_[0]->tag('Format', $date_format) }) },
96   'Rose::DB::Object::Metadata::Column::Timestamp' => sub { $_[0]->tag('Date', sub { $_[0]->tag('Format', $date_format) }) },
97   'Rose::DB::Object::Metadata::Column::Float'     => sub { $_[0]->tag('Numeric') },
98   'Rose::DB::Object::Metadata::Column::Boolean'   => sub { $_[0]
99     ->tag('AlphaNumeric')
100     ->tag('Map', sub { $_[0]
101       ->tag('From', 1)
102       ->tag('To', t8('true'))
103     })
104     ->tag('Map', sub { $_[0]
105       ->tag('From', 0)
106       ->tag('To', t8('false'))
107     })
108     ->tag('Map', sub { $_[0]
109       ->tag('From', '')
110       ->tag('To', t8('false'))
111     })
112   },
113 );
114
115 sub generate_export {
116   my ($self) = @_;
117
118   # verify data
119   $self->from && 'DateTime' eq ref $self->from or die 'need from date';
120   $self->to   && 'DateTime' eq ref $self->to   or die 'need to date';
121   $self->from <= $self->to                     or die 'from date must be earlier or equal than to date';
122   $self->tables && @{ $self->tables }          or die 'need tables';
123   for (@{ $self->tables }) {
124     next if $known_tables{$_};
125     die "unknown table '$_'";
126   }
127
128   # get data from those tables and save to csv
129   # for that we need to build queries that fetch all the columns
130   for ($self->sorted_tables) {
131     $self->do_csv_export($_);
132   }
133
134   $self->do_datev_csv_export;
135
136   # write xml file
137   $self->do_xml_file;
138
139   # add dtd
140   $self->files->{'gdpdu-01-08-2002.dtd'} = File::Spec->catfile('users', 'gdpdu-01-08-2002.dtd');
141
142   # make zip
143   my ($fh, $zipfile) = File::Temp::tempfile();
144   my $zip            = Archive::Zip->new;
145
146   while (my ($name, $file) = each %{ $self->files }) {
147     $zip->addFile($file, $name);
148   }
149
150   $zip->writeToFileHandle($fh) == Archive::Zip::AZ_OK() or die 'error writing zip file';
151   close($fh);
152
153   return $zipfile;
154 }
155
156 sub do_xml_file {
157   my ($self) = @_;
158
159   my ($fh, $filename) = File::Temp::tempfile();
160   binmode($fh, ':utf8');
161
162   $self->files->{'INDEX.XML'} = $filename;
163   push @{ $self->tempfiles }, $filename;
164
165   my $writer = XML::Writer->new(
166     OUTPUT      => $fh,
167     ENCODING    => 'UTF-8',
168   );
169
170   $self->writer($writer);
171   $self->writer->xmlDecl('UTF-8');
172   $self->writer->doctype('DataSet', undef, "gdpdu-01-08-2002.dtd");
173   $self->tag('DataSet', sub { $self
174     ->tag('Version', '1.0')
175     ->tag('DataSupplier', sub { $self
176       ->tag('Name', $self->client_name)
177       ->tag('Location', $self->client_location)
178       ->tag('Comment', $self->make_comment)
179     })
180     ->tag('Media', sub { $self
181       ->tag('Name', t8('DataSet #1', 1));
182       for (reverse $self->sorted_tables) { $self  # see CAVEATS for table order
183         ->table($_)
184       }
185       $self->do_datev_xml_table;
186     })
187   });
188   close($fh);
189 }
190
191 sub table {
192   my ($self, $table) = @_;
193   my $writer = $self->writer;
194
195   $self->tag('Table', sub { $self
196     ->tag('URL', "$table.csv")
197     ->tag('Name', $known_tables{$table}{name})
198     ->tag('Description', $known_tables{$table}{description})
199     ->tag('Validity', sub { $self
200       ->tag('Range', sub { $self
201         ->tag('From', $self->from->to_kivitendo(dateformat => 'dd.mm.yyyy'))
202         ->tag('To',   $self->to->to_kivitendo(dateformat => 'dd.mm.yyyy'))
203       })
204       ->tag('Format', $date_format)
205     })
206     ->tag('UTF8')
207     ->tag('DecimalSymbol', '.')
208     ->tag('DigitGroupingSymbol', '|')     # see CAVEATS in documentation
209     ->tag('VariableLength', sub { $self
210       ->tag('ColumnDelimiter', ',')       # see CAVEATS for missing RecordDelimiter
211       ->tag('TextEncapsulator', '"')
212       ->columns($table)
213       ->foreign_keys($table)
214     })
215   });
216 }
217
218 sub _table_columns {
219   my ($table) = @_;
220   my $package = SL::DB::Helper::Mappings::get_package_for_table($table);
221
222   # PrimaryKeys must come before regular columns, so partition first
223   partition_by {
224     $known_tables{$table}{primary_key}
225       ? 1 * ($_ eq $known_tables{$table}{primary_key})
226       : 1 * $_->is_primary_key_member
227   } $package->meta->columns;
228 }
229
230 sub columns {
231   my ($self, $table) = @_;
232
233   my %cols_by_primary_key = _table_columns($table);
234
235   for my $column (@{ $cols_by_primary_key{1} }) {
236     my $type = $column_types{ ref $column };
237
238     die "unknown col type @{[ ref $column ]}" unless $type;
239
240     $self->tag('VariablePrimaryKey', sub { $self
241       ->tag('Name', $column->name);
242       $type->($self);
243     })
244   }
245
246   for my $column (@{ $cols_by_primary_key{0} }) {
247     my $type = $column_types{ ref $column };
248
249     die "unknown col type @{[ ref $column]}" unless $type;
250
251     $self->tag('VariableColumn', sub { $self
252       ->tag('Name', $column->name);
253       $type->($self);
254     })
255   }
256
257   $self;
258 }
259
260 sub foreign_keys {
261   my ($self, $table) = @_;
262   my $package = SL::DB::Helper::Mappings::get_package_for_table($table);
263
264   my %requested = map { $_ => 1 } @{ $self->tables };
265
266   for my $rel ($package->meta->foreign_keys) {
267     next unless $requested{ $rel->class->meta->table };
268
269     # ok, now extract the columns used as foreign key
270     my %key_columns = $rel->key_columns;
271
272     if (1 != keys %key_columns) {
273       die "multi keys? we don't support this currently. fix it please";
274     }
275
276     if ($table eq $rel->class->meta->table) {
277       # self referential foreign keys are a PITA to export correctly. skip!
278       next;
279     }
280
281     $self->tag('ForeignKey', sub {
282       $_[0]->tag('Name', $_) for keys %key_columns;
283       $_[0]->tag('References', $rel->class->meta->table);
284    });
285   }
286 }
287
288 sub do_datev_xml_table {
289   my ($self) = @_;
290   my $writer = $self->writer;
291
292   $self->tag('Table', sub { $self
293     ->tag('URL', "transaction.csv")
294     ->tag('Name', t8('Transactions'))
295     ->tag('Description', t8('Transactions'))
296     ->tag('Validity', sub { $self
297       ->tag('Range', sub { $self
298         ->tag('From', $self->from->to_kivitendo(dateformat => 'dd.mm.yyyy'))
299         ->tag('To',   $self->to->to_kivitendo(dateformat => 'dd.mm.yyyy'))
300       })
301       ->tag('Format', $date_format)
302     })
303     ->tag('UTF8')
304     ->tag('DecimalSymbol', '.')
305     ->tag('DigitGroupingSymbol', '|')     # see CAVEATS in documentation
306     ->tag('VariableLength', sub { $self
307       ->tag('ColumnDelimiter', ',')       # see CAVEATS for missing RecordDelimiter
308       ->tag('TextEncapsulator', '"')
309       ->datev_columns
310       ->datev_foreign_keys
311     })
312   });
313 }
314
315 sub datev_columns {
316   my ($self, $table) = @_;
317
318   my %cols_by_primary_key = partition_by { $datev_column_defs{$_}{primary_key} } @datev_columns;
319   $::lxdebug->dump(0,  "cols", \%cols_by_primary_key);
320
321   for my $column (@{ $cols_by_primary_key{1} }) {
322     my $type = $column_types{ $datev_column_defs{$column}{type} };
323
324     die "unknown col type @{[ $column ]}" unless $type;
325
326     $self->tag('VariablePrimaryKey', sub { $self
327       ->tag('Name', $column);
328       $type->($self);
329     })
330   }
331
332   for my $column (@{ $cols_by_primary_key{''} }) {
333     my $type = $column_types{ $datev_column_defs{$column}{type} };
334
335     die "unknown col type @{[ ref $column]}" unless $type;
336
337     $self->tag('VariableColumn', sub { $self
338       ->tag('Name', $column);
339       $type->($self);
340     })
341   }
342
343   $self;
344 }
345
346 sub datev_foreign_keys {
347   my ($self) = @_;
348   # hard code weeee
349   $self->tag('ForeignKey', sub { $_[0]
350     ->tag('Name', 'customer_id')
351     ->tag('References', 'customer')
352   });
353   $self->tag('ForeignKey', sub { $_[0]
354     ->tag('Name', 'vendor_id')
355     ->tag('References', 'vendor')
356   });
357   $self->tag('ForeignKey', sub { $_[0]
358     ->tag('Name', $_)
359     ->tag('References', 'chart')
360   }) for qw(debit_accno credit_accno tax_accno);
361 }
362
363 sub do_datev_csv_export {
364   my ($self) = @_;
365
366   my $datev = SL::DATEV->new(from => $self->from, to => $self->to);
367
368   $datev->_get_transactions(from_to => $datev->fromto);
369
370   for my $transaction (@{ $datev->{DATEV} }) {
371     for my $entry (@{ $transaction }) {
372       $entry->{sortkey} = join '-', map { lc } (DateTime->from_kivitendo($entry->{transdate})->strftime('%Y%m%d'), $entry->{name}, $entry->{reference});
373     }
374   }
375
376   my @transactions = sort_by { $_->[0]->{sortkey} } @{ $datev->{DATEV} };
377
378   my $csv = Text::CSV_XS->new({ binary => 1, eol => "\r\n", sep_char => ",", quote_char => '"' });
379
380   my ($fh, $filename) = File::Temp::tempfile();
381   binmode($fh, ':utf8');
382
383   $self->files->{"transactions.csv"} = $filename;
384   push @{ $self->tempfiles }, $filename;
385
386   for my $transaction (@transactions) {
387     my $is_payment     = any { $_->{link} =~ m{A[PR]_paid} } @{ $transaction };
388
389     my ($soll, $haben) = map { $transaction->[$_] } ($transaction->[0]->{amount} > 0 ? (1, 0) : (0, 1));
390     my $tax            = defined($soll->{tax_accno})  ? $soll : $haben;
391     my $amount         = defined($soll->{net_amount}) ? $soll : $haben;
392     $haben->{notes}    = ($haben->{memo} || $soll->{memo}) if $haben->{memo} || $soll->{memo};
393     $haben->{notes}  //= '';
394     $haben->{notes}    =  SL::HTML::Util->strip($haben->{notes});
395     $haben->{notes}    =~ s{\r}{}g;
396     $haben->{notes}    =~ s{\n+}{ }g;
397
398     my %row            = (
399       customer_id      => $soll->{customer_id} || $haben->{customer_id},
400       vendor_id        => $soll->{vendor_id} || $haben->{vendor_id},
401       amount           => abs($amount->{amount}),
402       debit_accno      => $soll->{accno},
403       debit_accname    => $soll->{accname},
404       credit_accno     => $haben->{accno},
405       credit_accname   => $haben->{accname},
406       tax              => abs($amount->{amount}) - abs($amount->{net_amount}),
407       notes            => $haben->{notes},
408       (map { ($_ => $tax->{$_})                    } qw(taxkey tax_accname tax_accno)),
409       (map { ($_ => ($haben->{$_} // $soll->{$_})) } qw(acc_trans_id invnumber name vcnumber transdate)),
410     );
411
412     $csv->print($fh, [ map { $row{$_} } @datev_columns ]);
413   }
414
415   # and build xml spec for it
416 }
417
418 sub do_csv_export {
419   my ($self, $table) = @_;
420
421   my $csv = Text::CSV_XS->new({ binary => 1, eol => "\r\n", sep_char => ",", quote_char => '"' });
422
423   my ($fh, $filename) = File::Temp::tempfile();
424   binmode($fh, ':utf8');
425
426   $self->files->{"$table.csv"} = $filename;
427   push @{ $self->tempfiles }, $filename;
428
429   # in the right order (primary keys first)
430   my %cols_by_primary_key = _table_columns($table);
431   my @columns = (@{ $cols_by_primary_key{1} }, @{ $cols_by_primary_key{0} });
432   my %col_index = do { my $i = 0; map {; "$_" => $i++ } @columns };
433
434   # and normalize date stuff
435   my @select_tokens = map { (ref $_) =~ /Time/ ? $_->name . '::date' : $_->name } @columns;
436
437   my @where_tokens;
438   my @values;
439   if ($known_tables{$table}{transdate}) {
440     if ($self->from) {
441       push @where_tokens, "$known_tables{$table}{transdate} >= ?";
442       push @values, $self->from;
443     }
444     if ($self->to) {
445       push @where_tokens, "$known_tables{$table}{transdate} <= ?";
446       push @values, $self->to;
447     }
448   }
449   if ($known_tables{$table}{tables}) {
450     my ($col, @col_specs) = @{ $known_tables{$table}{tables} };
451     my %ids;
452     for (@col_specs) {
453       my ($ftable, $fkey) = split /\./, $_;
454       if (!exists $self->export_ids->{$ftable}{$fkey}) {
455          # check if we forgot to keep it
456          if (!grep { $_ eq $fkey } @{ $known_tables{$ftable}{keep} || [] }) {
457            die "unknown table spec '$_' for table $table, did you forget to keep $fkey in $ftable?"
458          } else {
459            # hmm, most likely just an empty set.
460            $self->export_ids->{$ftable}{$fkey} = {};
461          }
462       }
463       $ids{$_}++ for keys %{ $self->export_ids->{$ftable}{$fkey} };
464     }
465     if (keys %ids) {
466       push @where_tokens, "$col IN (@{[ join ',', ('?') x keys %ids ]})";
467       push @values, keys %ids;
468     } else {
469       push @where_tokens, '1=0';
470     }
471   }
472
473   my $where_clause = @where_tokens ? 'WHERE ' . join ' AND ', @where_tokens : '';
474
475   my $query = "SELECT " . join(', ', @select_tokens) . " FROM $table $where_clause";
476
477   my $sth = $::form->get_standard_dbh->prepare($query);
478   $sth->execute(@values) or die "error executing query $query: " . $sth->errstr;
479
480   while (my $row = $sth->fetch) {
481     for my $keep_col (@{ $known_tables{$table}{keep} || [] }) {
482       next if !$row->[$col_index{$keep_col}];
483       $self->export_ids->{$table}{$keep_col} ||= {};
484       $self->export_ids->{$table}{$keep_col}{$row->[$col_index{$keep_col}]}++;
485     }
486     s/\r\n/ /g for @$row; # see CAVEATS
487
488     $csv->print($fh, $row) or $csv->error_diag;
489   }
490   $sth->finish();
491 }
492
493 sub tag {
494   my ($self, $tag, $content) = @_;
495
496   $self->writer->startTag($tag);
497   if ('CODE' eq ref $content) {
498     $content->($self);
499   } else {
500     $self->writer->characters($content);
501   }
502   $self->writer->endTag;
503   return $self;
504 }
505
506 sub make_comment {
507   my $gdpdu_version = API_VERSION();
508   my $kivi_version  = $::form->read_version;
509   my $person        = $::myconfig{name};
510   my $contact       = join ', ',
511     (t8("Email") . ": $::myconfig{email}" ) x!! $::myconfig{email},
512     (t8("Tel")   . ": $::myconfig{tel}" )   x!! $::myconfig{tel},
513     (t8("Fax")   . ": $::myconfig{fax}" )   x!! $::myconfig{fax};
514
515   t8('DataSet for GDPdU version #1. Created with kivitendo #2 by #3 (#4)',
516     $gdpdu_version, $kivi_version, $person, $contact
517   );
518 }
519
520 sub client_name {
521   $_[0]->company
522 }
523
524 sub client_location {
525   $_[0]->location
526 }
527
528 sub sorted_tables {
529   my ($self) = @_;
530
531   my %given = map { $_ => 1 } @{ $self->tables };
532
533   grep { $given{$_} } @export_table_order;
534 }
535
536 sub all_tables {
537   my ($self, $yesno) = @_;
538
539   $self->tables(\@export_table_order) if $yesno;
540 }
541
542 sub init_files { +{} }
543 sub init_export_ids { +{} }
544 sub init_tempfiles { [] }
545 sub init_tables { [ grep { $known_tables{$_} } @export_table_order ] }
546
547 sub API_VERSION {
548   DateTime->new(year => 2002, month => 8, day => 14)->to_kivitendo;
549 }
550
551 sub DESTROY {
552   unlink $_ for @{ $_[0]->tempfiles || [] };
553 }
554
555 1;
556
557 __END__
558
559 =encoding utf-8
560
561 =head1 NAME
562
563 SL::GDPDU - IDEA export generator
564
565 =head1 FUNCTIONS
566
567 =over 4
568
569 =item C<new PARAMS>
570
571 Create new export object. C<PARAMS> may contain:
572
573 =over 4
574
575 =item company
576
577 The name of the company, needed for the supplier header
578
579 =item location
580
581 Location of the company, needed for the suupplier header
582
583 =item from
584
585 =item to
586
587 Will only include records in the specified date range. Data pulled from other
588 tables will be culled to match what is needed for these records.
589
590 =item tables
591
592 A list of tables to be exported.
593
594 =item all_tables
595
596 Alternative to C<tables>, enables all known tables.
597
598 =back
599
600 =item C<generate_export>
601
602 Do the work. Will return an absolut path to a temp file where all export files
603 are zipped together.
604
605 =back
606
607 =head1 CAVEATS
608
609 =over 4
610
611 =item *
612
613 Date format is shit. The official docs state that only C<YY>, C<YYYY>, C<MM>,
614 and C<DD> are supported, timestamps do not exist.
615
616 =item *
617
618 Number parsing seems to be fragile. Official docs state that behaviour for too
619 low C<Accuracy> settings is undefined. Accuracy of 0 is not taken to mean
620 Integer but instead generates a warning for redudancy.
621
622 There is no dedicated integer type.
623
624 =item *
625
626 Currently C<ar> and C<ap> have a foreign key to themself with the name
627 C<storno_id>. If this foreign key is present in the C<INDEX.XML> then the
628 storno records have to be too. Since this is extremely awkward to code and
629 confusing for the examiner as to why there are records outside of the time
630 range, this export skips all self-referential foreign keys.
631
632 =item *
633
634 Documentation for foreign keys is extremely weird. Instead of giving column
635 maps it assumes that foreign keys map to the primary keys given for the target
636 table, and in that order. Foreign keys to keys that are not primary seems to be
637 impossible. Changing type is also not allowed (which actually makes sense).
638 Hopefully there are no bugs there.
639
640 =item *
641
642 It's currently disallowed to export the whole dataset. It's not clear if this
643 is wanted.
644
645 =item *
646
647 It is not possible to set an empty C<DigiGroupingSymbol> since then the import
648 will just work with the default. This was asked in their forum, and the
649 response actually was:
650
651   Einfache Lösung: Definieren Sie das Tausendertrennzeichen als Komma, auch
652   wenn es nicht verwendet wird. Sollten Sie das Komma bereits als Feldtrenner
653   verwenden, so wählen Sie als Tausendertrennzeichen eine Alternative wie das
654   Pipe-Symbol |.
655
656 L<http://www.gdpdu-portal.com/forum/index.php?mode=thread&id=1392>
657
658 =item *
659
660 It is not possible to define a C<RecordDelimiter> with XML entities. &#x0A;
661 generates the error message:
662
663   C<RecordDelimiter>-Wert (&#x0A;) sollte immer aus ein oder zwei Zeichen
664   bestehen.
665
666 Instead we just use the implicit default RecordDelimiter CRLF.
667
668 =item *
669
670 Not confirmed yet:
671
672 Foreign keys seem only to work with previously defined tables (which would be
673 utterly insane).
674
675 =item *
676
677 The CSV import library used in IDEA is not able to parse newlines (or more
678 exactly RecordDelimiter) in data. So this export substites all of these with
679 spaces.
680
681 =back
682
683 =head1 AUTHOR
684
685 Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>
686
687 =cut