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);
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) ],
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
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) ] },
43 accno => t8('Account Number'),
44 description => t8('Description'),
49 department_1 => t8('Department 1'),
50 department_2 => t8('Department 2'),
51 street => t8('Street'),
52 zipcode => t8('Zipcode'),
54 country => t8('Country'),
55 contact => t8('Contact'),
58 email => t8('E-mail'),
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'),
67 $column_titles{$_} = $column_titles{customer_vendor} for qw(customer vendor);
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'), },
91 my @datev_columns = qw(
95 transdate invnumber amount
96 debit_accno debit_accname
97 credit_accno credit_accname
99 tax_accno tax_accname taxkey
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
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';
118 my $myconfig = { numberformat => $number_format };
120 # callbacks that produce the xml spec for these 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]
135 ->tag('To', t8('true'))
137 ->tag('Map', sub { $_[0]
139 ->tag('To', t8('false'))
141 ->tag('Map', sub { $_[0]
143 ->tag('To', t8('false'))
148 sub generate_export {
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 '$_'";
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($_);
167 $self->do_datev_csv_export;
173 $self->files->{'gdpdu-01-08-2002.dtd'} = File::Spec->catfile('users', 'gdpdu-01-08-2002.dtd');
176 my ($fh, $zipfile) = File::Temp::tempfile();
177 my $zip = Archive::Zip->new;
179 while (my ($name, $file) = each %{ $self->files }) {
180 $zip->addFile($file, $name);
183 $zip->writeToFileHandle($fh) == Archive::Zip::AZ_OK() or die 'error writing zip file';
192 my ($fh, $filename) = File::Temp::tempfile();
193 binmode($fh, ':utf8');
195 $self->files->{'INDEX.XML'} = $filename;
196 push @{ $self->tempfiles }, $filename;
198 my $writer = XML::Writer->new(
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)
213 ->tag('Media', sub { $self
214 ->tag('Name', t8('DataSet #1', 1));
215 for (reverse $self->sorted_tables) { $self # see CAVEATS for table order
218 $self->do_datev_xml_table;
225 my ($self, $table) = @_;
226 my $writer = $self->writer;
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'))
237 ->tag('Format', $date_format)
240 ->tag('DecimalSymbol', '.')
241 ->tag('DigitGroupingSymbol', '|') # see CAVEATS in documentation
242 ->tag('Range', sub { $self
243 ->tag('From', $self->csv_headers ? 2 : 1)
245 ->tag('VariableLength', sub { $self
246 ->tag('ColumnDelimiter', ',') # see CAVEATS for missing RecordDelimiter
247 ->tag('TextEncapsulator', '"')
249 ->foreign_keys($table)
256 my $package = SL::DB::Helper::Mappings::get_package_for_table($table);
259 my $use_white_list = 0;
260 if ($known_tables{$table}{columns}) {
262 $white_list{$_} = 1 for @{ $known_tables{$table}{columns} || [] };
265 # PrimaryKeys must come before regular columns, so partition first
267 $known_tables{$table}{primary_key}
268 ? 1 * ($_ eq $known_tables{$table}{primary_key})
269 : 1 * $_->is_primary_key_member
271 $use_white_list ? $white_list{$_->name} : 1
272 } $package->meta->columns;
276 my ($self, $table) = @_;
278 my %cols_by_primary_key = _table_columns($table);
280 for my $column (@{ $cols_by_primary_key{1} }) {
281 my $type = $column_types{ ref $column };
283 die "unknown col type @{[ ref $column ]}" unless $type;
285 $self->tag('VariablePrimaryKey', sub { $self
286 ->tag('Name', $column_titles{$table}{$column->name});
291 for my $column (@{ $cols_by_primary_key{0} }) {
292 my $type = $column_types{ ref $column };
294 die "unknown col type @{[ ref $column]}" unless $type;
296 $self->tag('VariableColumn', sub { $self
297 ->tag('Name', $column_titles{$table}{$column->name});
306 my ($self, $table) = @_;
307 my $package = SL::DB::Helper::Mappings::get_package_for_table($table);
309 my %requested = map { $_ => 1 } @{ $self->tables };
311 for my $rel ($package->meta->foreign_keys) {
312 next unless $requested{ $rel->class->meta->table };
314 # ok, now extract the columns used as foreign key
315 my %key_columns = $rel->key_columns;
317 if (1 != keys %key_columns) {
318 die "multi keys? we don't support this currently. fix it please";
321 if ($table eq $rel->class->meta->table) {
322 # self referential foreign keys are a PITA to export correctly. skip!
326 $self->tag('ForeignKey', sub {
327 $_[0]->tag('Name', $column_titles{$table}{$_}) for keys %key_columns;
328 $_[0]->tag('References', $rel->class->meta->table);
333 sub do_datev_xml_table {
335 my $writer = $self->writer;
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'))
346 ->tag('Format', $date_format)
349 ->tag('DecimalSymbol', '.')
350 ->tag('DigitGroupingSymbol', '|') # see CAVEATS in documentation
351 ->tag('Range', sub { $self
352 ->tag('From', $self->csv_headers ? 2 : 1)
354 ->tag('VariableLength', sub { $self
355 ->tag('ColumnDelimiter', ',') # see CAVEATS for missing RecordDelimiter
356 ->tag('TextEncapsulator', '"')
364 my ($self, $table) = @_;
366 my %cols_by_primary_key = partition_by { 1 * $datev_column_defs{$_}{primary_key} } @datev_columns;
368 for my $column (@{ $cols_by_primary_key{1} }) {
369 my $type = $column_types{ $datev_column_defs{$column}{type} };
371 die "unknown col type @{[ $column ]}" unless $type;
373 $self->tag('VariablePrimaryKey', sub { $self
374 ->tag('Name', $datev_column_defs{$column}{text});
379 for my $column (@{ $cols_by_primary_key{0} }) {
380 my $type = $column_types{ $datev_column_defs{$column}{type} };
382 die "unknown col type @{[ ref $column]}" unless $type;
384 $self->tag('VariableColumn', sub { $self
385 ->tag('Name', $datev_column_defs{$column}{text});
393 sub datev_foreign_keys {
396 $self->tag('ForeignKey', sub { $_[0]
397 ->tag('Name', $datev_column_defs{customer_id}{text})
398 ->tag('References', 'customer')
400 $self->tag('ForeignKey', sub { $_[0]
401 ->tag('Name', $datev_column_defs{vendor_id}{text})
402 ->tag('References', 'vendor')
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);
410 sub do_datev_csv_export {
413 my $datev = SL::DATEV->new(from => $self->from, to => $self->to);
415 $datev->_get_transactions(from_to => $datev->fromto);
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});
423 my @transactions = sort_by { $_->[0]->{sortkey} } @{ $datev->{DATEV} };
425 my $csv = Text::CSV_XS->new({ binary => 1, eol => "\r\n", sep_char => ",", quote_char => '"' });
427 my ($fh, $filename) = File::Temp::tempfile();
428 binmode($fh, ':utf8');
430 $self->files->{"transactions.csv"} = $filename;
431 push @{ $self->tempfiles }, $filename;
433 if ($self->csv_headers) {
434 $csv->print($fh, [ map { _normalize_cell($datev_column_defs{$_}{text}) } @datev_columns ]);
437 for my $transaction (@transactions) {
438 my $is_payment = any { $_->{link} =~ m{A[PR]_paid} } @{ $transaction };
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});
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)),
459 _normalize_cell($_) for values %row; # see CAVEATS
461 $csv->print($fh, [ map { $row{$_} } @datev_columns ]);
464 # and build xml spec for it
468 my ($self, $table) = @_;
470 my $csv = Text::CSV_XS->new({ binary => 1, eol => "\r\n", sep_char => ",", quote_char => '"' });
472 my ($fh, $filename) = File::Temp::tempfile();
473 binmode($fh, ':utf8');
475 $self->files->{"$table.csv"} = $filename;
476 push @{ $self->tempfiles }, $filename;
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 };
483 if ($self->csv_headers) {
484 $csv->print($fh, [ map { _normalize_cell($column_titles{$table}{$_->name}) } @columns ]) or die $csv->error_diag;
487 # and normalize date stuff
488 my @select_tokens = map { (ref $_) =~ /Time/ ? $_->name . '::date' : $_->name } @columns;
492 if ($known_tables{$table}{transdate}) {
494 push @where_tokens, "$known_tables{$table}{transdate} >= ?";
495 push @values, $self->from;
498 push @where_tokens, "$known_tables{$table}{transdate} <= ?";
499 push @values, $self->to;
502 if ($known_tables{$table}{tables}) {
503 my ($col, @col_specs) = @{ $known_tables{$table}{tables} };
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?"
512 # hmm, most likely just an empty set.
513 $self->export_ids->{$ftable}{$fkey} = {};
516 $ids{$_}++ for keys %{ $self->export_ids->{$ftable}{$fkey} };
519 push @where_tokens, "$col IN (@{[ join ',', ('?') x keys %ids ]})";
520 push @values, keys %ids;
522 push @where_tokens, '1=0';
526 my $where_clause = @where_tokens ? 'WHERE ' . join ' AND ', @where_tokens : '';
528 my $query = "SELECT " . join(', ', @select_tokens) . " FROM $table $where_clause";
530 my $sth = $::form->get_standard_dbh->prepare($query);
531 $sth->execute(@values) or die "error executing query $query: " . $sth->errstr;
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}]}++;
539 _normalize_cell($_) for @$row; # see CAVEATS
541 $csv->print($fh, $row) or $csv->error_diag;
547 my ($self, $tag, $content) = @_;
549 $self->writer->startTag($tag);
550 if ('CODE' eq ref $content) {
553 $self->writer->characters($content);
555 $self->writer->endTag;
560 my $gobd_version = API_VERSION();
561 my $kivi_version = $::form->read_version;
562 my $person = $::myconfig{name};
563 my $contact = join ', ',
564 (t8("Email") . ": $::myconfig{email}" ) x!! $::myconfig{email},
565 (t8("Tel") . ": $::myconfig{tel}" ) x!! $::myconfig{tel},
566 (t8("Fax") . ": $::myconfig{fax}" ) x!! $::myconfig{fax};
568 t8('DataSet for GoBD version #1. Created with kivitendo #2 by #3 (#4)',
569 $gobd_version, $kivi_version, $person, $contact
577 sub client_location {
584 my %given = map { $_ => 1 } @{ $self->tables };
586 grep { $given{$_} } @export_table_order;
590 my ($self, $yesno) = @_;
592 $self->tables(\@export_table_order) if $yesno;
595 sub _normalize_cell {
603 sub init_files { +{} }
604 sub init_export_ids { +{} }
605 sub init_tempfiles { [] }
606 sub init_tables { [ grep { $known_tables{$_} } @export_table_order ] }
607 sub init_csv_headers { 1 }
610 DateTime->new(year => 2002, month => 8, day => 14)->to_kivitendo;
614 unlink $_ for @{ $_[0]->tempfiles || [] };
625 SL::GoBD - IDEA export generator
633 Create new export object. C<PARAMS> may contain:
639 The name of the company, needed for the supplier header
643 Location of the company, needed for the supplier header
649 Will only include records in the specified date range. Data pulled from other
650 tables will be culled to match what is needed for these records.
654 Optional. If set, will include a header line in the exported CSV files. Default true.
658 Ooptional list of tables to be exported. Defaults to all tables.
662 Optional alternative to C<tables>, forces all known tables.
666 =item C<generate_export>
668 Do the work. Will return an absolute path to a temp file where all export files
675 Sigh. There are a lot of issues with the IDEA software that were found out by
678 =head2 Problems in the Specification
684 The specced date format is capable of only C<YY>, C<YYYY>, C<MM>,
685 and C<DD>. There are no timestamps or timezones.
689 Numbers have the same issue. There is not dedicated integer type, and hinting
690 at an integer type by setting accuracy to 0 generates a warning for redundant
693 Also the number parsing is documented to be fragile. Official docs state that
694 behaviour for too low C<Accuracy> settings is undefined.
698 Foreign key definition is broken. Instead of giving column maps it assumes that
699 foreign keys map to the primary keys given for the target table, and in that
700 order. Also the target table must be known in full before defining a foreign key.
702 As a consequence any additional keys apart from primary keys are not possible.
703 Self-referencing tables are also not possible.
707 The spec does not support splitting data sets into smaller chunks. For data
708 sets that exceed 700MB the spec helpfully suggests: "Use a bigger medium, such
713 It is not possible to set an empty C<DigitGroupingSymbol> since then the import
714 will just work with the default. This was asked in their forum, and the
715 response actually was to use a bogus grouping symbol that is not used:
717 Einfache Lösung: Definieren Sie das Tausendertrennzeichen als Komma, auch
718 wenn es nicht verwendet wird. Sollten Sie das Komma bereits als Feldtrenner
719 verwenden, so wählen Sie als Tausendertrennzeichen eine Alternative wie das
722 L<http://www.gdpdu-portal.com/forum/index.php?mode=thread&id=1392>
726 It is not possible to define a C<RecordDelimiter> with XML entities. 

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