Projekt

Allgemein

Profil

« Zurück | Weiter » 

Revision e2b04039

Von Sven Schöling vor etwa 8 Jahren hinzugefügt

  • ID e2b0403982edf5a329b4dfc9d2b169eac91c3dd8
  • Vorgänger db11732b
  • Nachfolger 5ce2e8bb

GoBD: GDPDU nach GoBD umbenannt

Unterschiede anzeigen:

SL/Controller/Gdpdu.pm
1
package SL::Controller::Gdpdu;
2

  
3
use strict;
4

  
5
use parent qw(SL::Controller::Base);
6

  
7
use DateTime;
8
use SL::GDPDU;
9
use SL::Locale::String qw(t8);
10
use SL::Helper::Flash;
11

  
12
use SL::DB::AccTransaction;
13

  
14
use Rose::Object::MakeMethods::Generic (
15
  'scalar --get_set_init' => [ qw(from to) ],
16
);
17

  
18
__PACKAGE__->run_before('check_auth');
19

  
20
sub action_filter {
21
  my ($self) = @_;
22

  
23
  $self->from(DateTime->today->add(years => -1)->add(days => 1)) if !$self->from;
24
  $self->to(DateTime->today)                                     if !$self->to;
25

  
26
  $::request->layout->add_javascripts('kivi.Gdpdu.js');
27
  $self->render('gdpdu/filter', current_year => DateTime->today->year, title => t8('GDPDU Export'));
28
}
29

  
30
sub action_export {
31
  my ($self) = @_;
32

  
33
  if (!$self->check_inputs) {
34
    $self->action_filter;
35
    return;
36
  }
37

  
38
  my $gdpdu = SL::GDPDU->new(
39
    company    => $::instance_conf->get_company,
40
    location   => $::instance_conf->get_address,
41
    from       => $self->from,
42
    to         => $self->to,
43
  );
44

  
45
  my $filename = $gdpdu->generate_export;
46

  
47
  $self->send_file($filename, name => t8('gdpdu-#1-#2.zip', $self->from->ymd, $self->to->ymd), unlink => 1);
48
}
49

  
50
#--- other stuff
51

  
52
sub check_auth { $::auth->assert('report') }
53

  
54
sub check_inputs {
55
  my ($self) = @_;
56

  
57
  my $error = 0;
58

  
59
  if ($::form->{method} eq 'year') {
60
    if ($::form->{year}) {
61
      $self->from(DateTime->new(year => $::form->{year}, month => 1,  day => 1));
62
      $self->to(  DateTime->new(year => $::form->{year}, month => 12, day => 31));
63
    } else {
64
      $error = 1;
65
      flash('error', t8('No year given for method year'));
66
    }
67
  } else {
68
    if (!$::form->{from}) {
69
      my $epoch = DateTime->new(day => 1, month => 1, year => 1900);
70
      flash('info', t8('No start date given, setting to #1', $epoch->to_kivitendo));
71
      $self->from($epoch);
72
    }
73

  
74
    if (!$::form->{to}) {
75
      flash('info', t8('No end date given, setting to today'));
76
      $self->to(DateTime->today);
77
    }
78
  }
79

  
80
  !$error;
81
}
82

  
83
sub available_years {
84
  my ($self) = @_;
85

  
86
  my $first_trans = SL::DB::Manager::AccTransaction->get_first(sort_by => 'transdate', limit => 1);
87

  
88
  return [] unless $first_trans;
89
  return [ reverse $first_trans->transdate->year .. DateTime->today->year ];
90
}
91

  
92
sub init_from { DateTime->from_kivitendo($::form->{from}) }
93
sub init_to { DateTime->from_kivitendo($::form->{to}) }
94

  
95
1;
SL/Controller/GoBD.pm
1
package SL::Controller::GoBD;
2

  
3
use strict;
4

  
5
use parent qw(SL::Controller::Base);
6

  
7
use DateTime;
8
use SL::GoBD;
9
use SL::Locale::String qw(t8);
10
use SL::Helper::Flash;
11

  
12
use SL::DB::AccTransaction;
13

  
14
use Rose::Object::MakeMethods::Generic (
15
  'scalar --get_set_init' => [ qw(from to) ],
16
);
17

  
18
__PACKAGE__->run_before('check_auth');
19

  
20
sub action_filter {
21
  my ($self) = @_;
22

  
23
  $self->from(DateTime->today->add(years => -1)->add(days => 1)) if !$self->from;
24
  $self->to(DateTime->today)                                     if !$self->to;
25

  
26
  $::request->layout->add_javascripts('kivi.GoBD.js');
27
  $self->render('gobd/filter', current_year => DateTime->today->year, title => t8('GoBD Export'));
28
}
29

  
30
sub action_export {
31
  my ($self) = @_;
32

  
33
  if (!$self->check_inputs) {
34
    $self->action_filter;
35
    return;
36
  }
37

  
38
  my $gobd = SL::GoBD->new(
39
    company    => $::instance_conf->get_company,
40
    location   => $::instance_conf->get_address,
41
    from       => $self->from,
42
    to         => $self->to,
43
  );
44

  
45
  my $filename = $gobd->generate_export;
46

  
47
  $self->send_file($filename, name => t8('gobd-#1-#2.zip', $self->from->ymd, $self->to->ymd), unlink => 1);
48
}
49

  
50
#--- other stuff
51

  
52
sub check_auth { $::auth->assert('report') }
53

  
54
sub check_inputs {
55
  my ($self) = @_;
56

  
57
  my $error = 0;
58

  
59
  if ($::form->{method} eq 'year') {
60
    if ($::form->{year}) {
61
      $self->from(DateTime->new(year => $::form->{year}, month => 1,  day => 1));
62
      $self->to(  DateTime->new(year => $::form->{year}, month => 12, day => 31));
63
    } else {
64
      $error = 1;
65
      flash('error', t8('No year given for method year'));
66
    }
67
  } else {
68
    if (!$::form->{from}) {
69
      my $epoch = DateTime->new(day => 1, month => 1, year => 1900);
70
      flash('info', t8('No start date given, setting to #1', $epoch->to_kivitendo));
71
      $self->from($epoch);
72
    }
73

  
74
    if (!$::form->{to}) {
75
      flash('info', t8('No end date given, setting to today'));
76
      $self->to(DateTime->today);
77
    }
78
  }
79

  
80
  !$error;
81
}
82

  
83
sub available_years {
84
  my ($self) = @_;
85

  
86
  my $first_trans = SL::DB::Manager::AccTransaction->get_first(sort_by => 'transdate', limit => 1);
87

  
88
  return [] unless $first_trans;
89
  return [ reverse $first_trans->transdate->year .. DateTime->today->year ];
90
}
91

  
92
sub init_from { DateTime->from_kivitendo($::form->{from}) }
93
sub init_to { DateTime->from_kivitendo($::form->{to}) }
94

  
95
1;
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 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'),
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 $gdpdu_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 GDPdU version #1. Created with kivitendo #2 by #3 (#4)',
569
    $gdpdu_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::GDPDU - 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
Despite the standard specifying UTF-8 as a valid encoding the IDEA software
752
will just downgrade everything to latin1.
753

  
754
=back
755

  
756
=head2 Problems outside of the software
757

  
758
=over 4
759

  
760
=item *
761

  
762
The law states that "all business related data" should be made available. In
763
practice there's no definition for what makes data "business related", and
764
different auditors seems to want different data.
765

  
766
Currently we export most of the transactional data with supplementing
767
customers, vendors and chart of accounts.
768

  
769
=item *
770

  
771
While the standard explicitely state to provide data normalized, in practice
772
autditors aren't trained database operators and can not create complex vies on
773
normalized data on their own. The reason this works for other software is, that
774
DATEV and SAP seem to have written import plugins for their internal formats in
775
the IDEA software.
776

  
777
So what is really exported is not unlike a DATEV export. Each transaction gets
778
splitted into chunks of 2 positions (3 with tax on one side). Those get
779
denormalized into a single data row with credfit/debit/tax fields. The charts
780
get denormalized into it as well, in addition to their account number serving
781
as a foreign key.
782

  
783
Customers and vendors get denormalized into this as well, but are linked by ids
784
to their tables. And the reason for this is...
785

  
786
=item *
787

  
788
Some auditors do not have a full license of the IDEA software, and
789
can't do table joins.
790

  
791
=back
792

  
793
=head1 AUTHOR
794

  
795
Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>
796

  
797
=cut
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'),
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;
... Dieser Diff wurde abgeschnitten, weil er die maximale Anzahl anzuzeigender Zeilen überschreitet.

Auch abrufbar als: Unified diff