Projekt

Allgemein

Profil

Herunterladen (45,7 KB) Statistiken
| Zweig: | Markierung: | Revision:
#=====================================================================
# kivitendo ERP
# Copyright (c) 2004
#
# Author: Philip Reetz
# Email: p.reetz@linet-services.de
# Web: http://www.lx-office.org
#
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
# MA 02110-1335, USA.
#======================================================================
#
# Datev export module
#======================================================================

package SL::DATEV;

use utf8;
use strict;

use SL::DBUtils;
use SL::DATEV::CSV;
use SL::DB;
use Encode qw(encode);
use SL::HTML::Util ();
use SL::Iconv;
use SL::Locale::String qw(t8);
use SL::VATIDNr;

use Archive::Zip;
use Data::Dumper;
use DateTime;
use Exporter qw(import);
use File::Path;
use IO::File;
use List::MoreUtils qw(any);
use List::Util qw(min max sum);
use List::UtilsBy qw(partition_by sort_by);
use Text::CSV_XS;
use Time::HiRes qw(gettimeofday);
use XML::LibXML;

{
my $i = 0;
use constant {
DATEV_ET_BUCHUNGEN => $i++,
DATEV_ET_STAMM => $i++,
DATEV_ET_CSV => $i++,

DATEV_FORMAT_KNE => $i++,
DATEV_FORMAT_OBE => $i++,
DATEV_FORMAT_CSV => $i++,
};
}

my @export_constants = qw(DATEV_ET_BUCHUNGEN DATEV_ET_STAMM DATEV_ET_CSV DATEV_FORMAT_KNE DATEV_FORMAT_OBE DATEV_FORMAT_CSV);
our @EXPORT_OK = (@export_constants);
our %EXPORT_TAGS = (CONSTANTS => [ @export_constants ]);


sub new {
my $class = shift;
my %data = @_;

my $obj = bless {}, $class;

$obj->$_($data{$_}) for keys %data;

$obj;
}

sub exporttype {
my $self = shift;
$self->{exporttype} = $_[0] if @_;
return $self->{exporttype};
}

sub has_exporttype {
defined $_[0]->{exporttype};
}

sub format {
my $self = shift;
$self->{format} = $_[0] if @_;
return $self->{format};
}

sub has_format {
defined $_[0]->{format};
}

sub _get_export_path {
$main::lxdebug->enter_sub();

my ($a, $b) = gettimeofday();
my $path = _get_path_for_download_token("${a}-${b}-${$}");

mkpath($path) unless (-d $path);

$main::lxdebug->leave_sub();

return $path;
}

sub _get_path_for_download_token {
$main::lxdebug->enter_sub();

my $token = shift || '';
my $path;

if ($token =~ m|^(\d+)-(\d+)-(\d+)$|) {
$path = $::lx_office_conf{paths}->{userspath} . "/datev-export-${1}-${2}-${3}/";
}

$main::lxdebug->leave_sub();

return $path;
}

sub _get_download_token_for_path {
$main::lxdebug->enter_sub();

my $path = shift;
my $token;

if ($path =~ m|.*datev-export-(\d+)-(\d+)-(\d+)/?$|) {
$token = "${1}-${2}-${3}";
}

$main::lxdebug->leave_sub();

return $token;
}

sub download_token {
my $self = shift;
$self->{download_token} = $_[0] if @_;
return $self->{download_token} ||= _get_download_token_for_path($self->export_path);
}

sub export_path {
my ($self) = @_;

return $self->{export_path} ||= _get_path_for_download_token($self->{download_token}) || _get_export_path();
}

sub add_filenames {
my $self = shift;
push @{ $self->{filenames} ||= [] }, @_;
}

sub filenames {
return @{ $_[0]{filenames} || [] };
}

sub add_error {
my $self = shift;
push @{ $self->{errors} ||= [] }, @_;
}

sub errors {
return @{ $_[0]{errors} || [] };
}

sub add_net_gross_differences {
my $self = shift;
push @{ $self->{net_gross_differences} ||= [] }, @_;
}

sub net_gross_differences {
return @{ $_[0]{net_gross_differences} || [] };
}

sub sum_net_gross_differences {
return sum $_[0]->net_gross_differences;
}

sub from {
my $self = shift;

if (@_) {
die "Invalid type, need DateTime Object" unless ref $_[0] eq 'DateTime';
$self->{from} = $_[0];
}

return $self->{from};
}

sub to {
my $self = shift;

if (@_) {
die "Invalid type, need DateTime Object" unless ref $_[0] eq 'DateTime';
$self->{to} = $_[0];
}

return $self->{to};
}

sub trans_id {
my $self = shift;

if (@_) {
$self->{trans_id} = $_[0];
}

die "illegal trans_id passed for DATEV export: " . $self->{trans_id} . "\n" unless $self->{trans_id} =~ m/^\d+$/;

return $self->{trans_id};
}

sub warnings {
my $self = shift;

if (@_) {
$self->{warnings} = [@_];
} else {
return $self->{warnings};
}
}

sub use_pk {
my $self = shift;

if (@_) {
$self->{use_pk} = $_[0];
}

return $self->{use_pk};
}

sub accnofrom {
my $self = shift;

if (@_) {
$self->{accnofrom} = $_[0];
}

return $self->{accnofrom};
}

sub accnoto {
my $self = shift;

if (@_) {
$self->{accnoto} = $_[0];
}

return $self->{accnoto};
}


sub dbh {
my $self = shift;

if (@_) {
$self->{dbh} = $_[0];
$self->{provided_dbh} = 1;
}

$self->{dbh} ||= SL::DB->client->dbh;
}

sub provided_dbh {
$_[0]{provided_dbh};
}

sub clean_temporary_directories {
$::lxdebug->enter_sub;

foreach my $path (glob($::lx_office_conf{paths}->{userspath} . "/datev-export-*")) {
next unless -d $path;

my $mtime = (stat($path))[9];
next if ((time() - $mtime) < 8 * 60 * 60);

rmtree $path;
}

$::lxdebug->leave_sub;
}

sub get_datev_stamm {
return $_[0]{stamm} ||= selectfirst_hashref_query($::form, $_[0]->dbh, 'SELECT * FROM datev');
}

sub save_datev_stamm {
my ($self, $data) = @_;

SL::DB->client->with_transaction(sub {
do_query($::form, $self->dbh, 'DELETE FROM datev');

my @columns = qw(beraternr beratername dfvkz mandantennr datentraegernr abrechnungsnr);

my $query = "INSERT INTO datev (" . join(', ', @columns) . ") VALUES (" . join(', ', ('?') x @columns) . ")";
do_query($::form, $self->dbh, $query, map { $data->{$_} } @columns);
1;
}) or do { die SL::DB->client->error };
}

sub export {
my ($self) = @_;

return $self->csv_export;
}

sub csv_export {
my ($self) = @_;
my $result;

die 'no exporttype set!' unless $self->has_exporttype;

if ($self->exporttype == DATEV_ET_BUCHUNGEN) {

$self->generate_datev_data(from_to => $self->fromto);
return if $self->errors;

my $datev_csv = SL::DATEV::CSV->new(
datev_lines => $self->generate_datev_lines,
from => $self->from,
to => $self->to,
locked => $self->locked,
);


my $filename = "EXTF_DATEV_kivitendo" . $self->from->ymd() . '-' . $self->to->ymd() . ".csv";

my $csv = Text::CSV_XS->new({
binary => 1,
sep_char => ";",
always_quote => 1,
eol => "\r\n",
}) or die "Cannot use CSV: ".Text::CSV_XS->error_diag();

# get encoding from defaults - use cp1252 if DATEV strict export is used
my $enc = ($::instance_conf->get_datev_export_format eq 'cp1252') ? 'cp1252' : 'utf-8';
my $csv_file = IO::File->new($self->export_path . '/' . $filename, ">:encoding($enc)") or die "Can't open: $!";

$csv->print($csv_file, $_) for @{ $datev_csv->header };
$csv->print($csv_file, $_) for @{ $datev_csv->lines };
$csv_file->close;
$self->{warnings} = $datev_csv->warnings;

$self->_create_xml_and_documents if $self->{documents} && $self->{guids} && %{ $self->{guids} };

# convert utf-8 to cp1252//translit if set
if ($::instance_conf->get_datev_export_format eq 'cp1252-translit') {

my $filename_translit = "EXTF_DATEV_kivitendo_translit" . $self->from->ymd() . '-' . $self->to->ymd() . ".csv";
open my $fh_in, '<:encoding(UTF-8)', $self->export_path . '/' . $filename or die "could not open $filename for reading: $!";
open my $fh_out, '>', $self->export_path . '/' . $filename_translit or die "could not open $filename_translit for writing: $!";

my $converter = SL::Iconv->new("utf-8", "cp1252//translit");

print $fh_out $converter->convert($_) while <$fh_in>;
close $fh_in;
close $fh_out;

unlink $self->export_path . '/' . $filename or warn "Could not unlink $filename: $!";
$filename = $filename_translit;
}

return { download_token => $self->download_token, filenames => $filename };

} else {
die 'unrecognized exporttype';
}

return $result;
}

sub fromto {
my ($self) = @_;

return unless $self->from && $self->to;

return "transdate >= '" . $self->from->to_lxoffice . "' and transdate <= '" . $self->to->to_lxoffice . "'";
}

sub _sign {
$_[0] <=> 0;
}

sub locked {
my $self = shift;

if (@_) {
$self->{locked} = $_[0];
}
return $self->{locked};
}

sub imported {
my $self = shift;

if (@_) {
$self->{imported} = $_[0];
}
return $self->{imported};
}

sub documents {
my $self = shift;

if (@_) {
$self->{documents} = $_[0];
}
return $self->{documents};
}

sub _create_xml_and_documents {
my $self = shift;

die "No guids" unless %{ $self->{guids} };

my $today = DateTime->now_local;
my $doc = XML::LibXML::Document->new('1.0', 'utf-8');

my $root = $doc->createElement('archive');
#<archive xmlns="http://xml.datev.de/bedi/tps/document/v05.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xml.datev.de/bedi/tps/document/v05.0 Document_v050.xsd" version="5.0" generatingSystem="DATEV-Musterdaten">

$root->setAttribute('xmlns' => 'http://xml.datev.de/bedi/tps/document/v05.0');
$root->setAttribute('xmlns:xsi' => 'http://www.w3.org/2001/XMLSchema-instance');
$root->setAttribute('xsi:schemaLocation' => 'http://xml.datev.de/bedi/tps/document/v05.0 Document_v050.xsd');
$root->setAttribute('version' => '5.0');
$root->setAttribute('generatingSystem' => 'kivitendo');

# header with timestamp
my $header_tag = $doc->createElement('header');
$root->appendChild($header_tag);
my $date_tag = $doc->createElement('date');
$date_tag->appendTextNode($today);
$header_tag->appendChild($date_tag);


# content
my $content_node = $doc->createElement('content');
$root->appendChild($content_node);
# we have n document childs
foreach my $guid (keys %{ $self->{guids} }) {
# 1. get filename and file location
my $file_version = SL::DB::Manager::FileVersion->find_by(guid => $guid);
die "Invalid guid $guid" unless ref $file_version eq 'SL::DB::FileVersion';
# file_name has to be unique add guid if needed
my $filename_for_zip = (exists $self->{files}{$file_version->file_name})
? $file_version->file_name . '__' . $guid
: $file_version->file_name;
$filename_for_zip = $guid . '.pdf';
$self->{files}{$filename_for_zip} = $file_version->get_system_location;
# create xml metadata for files
my $document_node = $doc->createElement('document');
# set attr
$document_node->setAttribute('guid' => $guid);
$document_node->setAttribute('processID' => '1');
$document_node->setAttribute('type' => '1');
$content_node->appendChild($document_node);
my $extension_node = $doc->createElement('extension');
$extension_node->setAttribute('xsi:type' => 'File');
$extension_node->setAttribute('name' => $filename_for_zip);
$document_node->appendChild($extension_node);
}
$doc->setDocumentElement($root);

# create Archive::Zip in Export Path
my $zip = Archive::Zip->new();
# add metadata document
$zip->addString($doc->toString(), 'document.xml');
# add real files
foreach my $filename (keys %{ $self->{files} }) {
# my $enc_filename = encode('Windows-1252', $filename);
$zip->addFile($self->{files}{$filename}, $filename);
}
die "Cannot write Belege-XML.zip" unless ($zip->writeToFileNamed($self->export_path . 'Belege-XML.zip')
== Archive::Zip::AZ_OK());
}

sub generate_datev_data {
$main::lxdebug->enter_sub();

my ($self, %params) = @_;
my $fromto = $params{from_to} // '';
my $progress_callback = $params{progress_callback} || sub {};

my $form = $main::form;

my $trans_id_filter = '';
my $ar_department_id_filter = '';
my $ap_department_id_filter = '';
my $gl_department_id_filter = '';
if ( $form->{department_id} ) {
$ar_department_id_filter = " AND ar.department_id = ? ";
$ap_department_id_filter = " AND ap.department_id = ? ";
$gl_department_id_filter = " AND gl.department_id = ? ";
}

my ($gl_itime_filter, $ar_itime_filter, $ap_itime_filter);
if ( $form->{gldatefrom} ) {
$gl_itime_filter = " AND gl.itime >= ? ";
$ar_itime_filter = " AND ar.itime >= ? ";
$ap_itime_filter = " AND ap.itime >= ? ";
} else {
$gl_itime_filter = "";
$ar_itime_filter = "";
$ap_itime_filter = "";
}

if ( $self->{trans_id} ) {
# ignore dates when trans_id is passed so that the entire transaction is
# checked, not just either the initial bookings or the subsequent payments
# (the transdates will likely differ)
$fromto = '';
$trans_id_filter = 'ac.trans_id = ' . $self->trans_id;
} else {
$fromto =~ s/transdate/ac\.transdate/g;
};

my ($notsplitindex);

my $filter = ''; # Useful for debugging purposes

my %all_taxchart_ids = selectall_as_map($form, $self->dbh, qq|SELECT DISTINCT chart_id, TRUE AS is_set FROM tax|, 'chart_id', 'is_set');

my $ar_accno = "c.accno";
my $ap_accno = "c.accno";
if ( $self->use_pk ) {
$ar_accno = "CASE WHEN ac.chart_link = 'AR' THEN ct.customernumber ELSE c.accno END as accno";
$ap_accno = "CASE WHEN ac.chart_link = 'AP' THEN ct.vendornumber ELSE c.accno END as accno";
}
my $gl_imported;
if ( !$self->imported ) {
$gl_imported = " AND NOT imported";
}

my $query =
qq|SELECT ac.acc_trans_id, ac.transdate, ac.gldate, ac.trans_id,ar.id, ac.amount, ac.taxkey, ac.memo,
ar.invnumber, ar.duedate, ar.amount as umsatz, COALESCE(ar.tax_point, ar.deliverydate) AS deliverydate, ar.itime::date,
ct.name, ct.ustid, ct.customernumber AS vcnumber, ct.id AS customer_id, NULL AS vendor_id,
$ar_accno, c.description AS accname, c.taxkey_id as charttax, c.datevautomatik, c.id, ac.chart_link AS link,
ar.invoice,
t.rate AS taxrate, t.taxdescription,
'ar' as table,
tc.accno AS tax_accno, tc.description AS tax_accname,
ar.department_id,
ar.notes,
project.projectnumber as projectnumber, project.description as projectdescription,
department.description as departmentdescription
FROM acc_trans ac
LEFT JOIN ar ON (ac.trans_id = ar.id)
LEFT JOIN customer ct ON (ar.customer_id = ct.id)
LEFT JOIN chart c ON (ac.chart_id = c.id)
LEFT JOIN tax t ON (ac.tax_id = t.id)
LEFT JOIN chart tc ON (t.chart_id = tc.id)
LEFT JOIN department ON (department.id = ar.department_id)
LEFT JOIN project ON (project.id = ar.globalproject_id)
WHERE (ar.id IS NOT NULL)
AND $fromto
$trans_id_filter
$ar_itime_filter
$ar_department_id_filter
$filter

UNION ALL

SELECT ac.acc_trans_id, ac.transdate, ac.gldate, ac.trans_id,ap.id, ac.amount, ac.taxkey, ac.memo,
ap.invnumber, ap.duedate, ap.amount as umsatz, COALESCE(ap.tax_point, ap.deliverydate) AS deliverydate, ap.itime::date,
ct.name, ct.ustid, ct.vendornumber AS vcnumber, NULL AS customer_id, ct.id AS vendor_id,
$ap_accno, c.description AS accname, c.taxkey_id as charttax, c.datevautomatik, c.id, ac.chart_link AS link,
ap.invoice,
t.rate AS taxrate, t.taxdescription,
'ap' as table,
tc.accno AS tax_accno, tc.description AS tax_accname,
ap.department_id,
ap.notes,
project.projectnumber as projectnumber, project.description as projectdescription,
department.description as departmentdescription
FROM acc_trans ac
LEFT JOIN ap ON (ac.trans_id = ap.id)
LEFT JOIN vendor ct ON (ap.vendor_id = ct.id)
LEFT JOIN chart c ON (ac.chart_id = c.id)
LEFT JOIN tax t ON (ac.tax_id = t.id)
LEFT JOIN chart tc ON (t.chart_id = tc.id)
LEFT JOIN department ON (department.id = ap.department_id)
LEFT JOIN project ON (project.id = ap.globalproject_id)
WHERE (ap.id IS NOT NULL)
AND $fromto
$trans_id_filter
$ap_itime_filter
$ap_department_id_filter
$filter

UNION ALL

SELECT ac.acc_trans_id, ac.transdate, ac.gldate, ac.trans_id,gl.id, ac.amount, ac.taxkey, ac.memo,
gl.reference AS invnumber, NULL AS duedate, ac.amount as umsatz, COALESCE(gl.tax_point, gl.deliverydate) AS deliverydate, gl.itime::date,
gl.description AS name, NULL as ustid, '' AS vcname, NULL AS customer_id, NULL AS vendor_id,
c.accno, c.description AS accname, c.taxkey_id as charttax, c.datevautomatik, c.id, ac.chart_link AS link,
FALSE AS invoice,
t.rate AS taxrate, t.taxdescription,
'gl' as table,
tc.accno AS tax_accno, tc.description AS tax_accname,
gl.department_id,
gl.notes,
'' as projectnumber, '' as projectdescription,
department.description as departmentdescription
FROM acc_trans ac
LEFT JOIN gl ON (ac.trans_id = gl.id)
LEFT JOIN chart c ON (ac.chart_id = c.id)
LEFT JOIN tax t ON (ac.tax_id = t.id)
LEFT JOIN chart tc ON (t.chart_id = tc.id)
LEFT JOIN department ON (department.id = gl.department_id)
WHERE (gl.id IS NOT NULL)
AND $fromto
$trans_id_filter
$gl_itime_filter
$gl_department_id_filter
$gl_imported
AND NOT EXISTS (SELECT gl_id from ap_gl where gl_id = gl.id)
$filter

ORDER BY trans_id, acc_trans_id|;

my @query_args;
if ( $form->{gldatefrom} or $form->{department_id} ) {

for ( 1 .. 3 ) {
if ( $form->{gldatefrom} ) {
my $glfromdate = $::locale->parse_date_to_object($form->{gldatefrom});
die "illegal data" unless ref($glfromdate) eq 'DateTime';
push(@query_args, $glfromdate);
}
if ( $form->{department_id} ) {
push(@query_args, $form->{department_id});
}
}
}

my $sth = prepare_execute_query($form, $self->dbh, $query, @query_args);
$self->{DATEV} = [];

my $counter = 0;
my $continue = 1; #
my $name;
while ( $continue && (my $ref = $sth->fetchrow_hashref("NAME_lc")) ) {
last unless $ref; # for single transactions
$counter++;
if (($counter % 500) == 0) {
$progress_callback->($counter);
}

my $trans = [ $ref ];

my $count = $ref->{amount};
my $firstrun = 1;

# if the amount of a booking in a group is smaller than 0.02, any tax
# amounts will likely be smaller than 1 cent, so go into subcent mode
my $subcent = abs($count) < 0.02;

# records from acc_trans are ordered by trans_id and acc_trans_id
# first check for unbalanced ledger inside one trans_id
# there may be several groups inside a trans_id, e.g. the original booking and the payment
# each group individually should be exactly balanced and each group
# individually needs its own datev lines

# keep fetching new acc_trans lines until the end of a balanced group is reached
while (abs($count) > 0.01 || $firstrun || ($subcent && abs($count) > 0.005)) {
my $ref2 = $sth->fetchrow_hashref("NAME_lc");
unless ( $ref2 ) {
$continue = 0;
last;
};

# check if trans_id of current acc_trans line is still the same as the
# trans_id of the first line in group, i.e. we haven't finished a 0-group
# before moving on to the next trans_id, error will likely be in the old
# trans_id.

if ($ref2->{trans_id} != $trans->[0]->{trans_id}) {
require SL::DB::Manager::AccTransaction;
if ( $trans->[0]->{trans_id} ) {
my $acc_trans_obj = SL::DB::Manager::AccTransaction->get_first(where => [ trans_id => $trans->[0]->{trans_id} ]);
$self->add_error(t8("Export error in transaction #1: Unbalanced ledger before next transaction (#2)",
$acc_trans_obj->transaction_name, $ref2->{trans_id})
);
};
return;
}

push @{ $trans }, $ref2;

$count += $ref2->{amount};
$firstrun = 0;
}

foreach my $i (0 .. scalar(@{ $trans }) - 1) {
my $ref = $trans->[$i];
my $prev_ref = 0 < $i ? $trans->[$i - 1] : undef;
if ( $all_taxchart_ids{$ref->{id}}
&& ($ref->{link} =~ m/(?:AP_tax|AR_tax)/)
&& ( ($prev_ref && $prev_ref->{taxkey} && (_sign($ref->{amount}) == _sign($prev_ref->{amount})))
|| $ref->{invoice})) {
$ref->{is_tax} = 1;
}

if ( !$ref->{invoice} # we have a non-invoice booking (=gl)
&& $ref->{is_tax} # that has "is_tax" set
&& !($prev_ref->{is_tax}) # previous line wasn't is_tax
&& (_sign($ref->{amount}) == _sign($prev_ref->{amount}))) { # and sign same as previous sign
$trans->[$i - 1]->{tax_amount} = $ref->{amount};
}
}

my $absumsatz = 0;
if (scalar(@{$trans}) <= 2) {
push @{ $self->{DATEV} }, $trans;
next;
}

# determine at which array position the reference value (called absumsatz) is
# and which amount it has

for my $j (0 .. (scalar(@{$trans}) - 1)) {

# Three cases:
# 1: gl transaction (Dialogbuchung), invoice is false, no double split booking allowed

# 2: sales or vendor invoice (Verkaufs- und Einkaufsrechnung): invoice is
# true, instead of absumsatz use link AR/AP (there should only be one
# entry)

# 3. AR/AP transaction (Kreditoren- und Debitorenbuchung): invoice is false,