From 6717843f18b45414ae6ab36af727c6ba39473605 Mon Sep 17 00:00:00 2001 From: metacube Date: Thu, 7 May 2026 14:08:54 +0200 Subject: [PATCH] Add finance probe Spain reconciliation updates --- .../DE_Beispiel_Export_Daten.xlsx | Bin 0 -> 10630 bytes TrafagSalesExporter/HANDOFF_2026-04-15.md | 119 +++- TrafagSalesExporter/LLM_SYSTEM_GUIDE.md | 46 +- TrafagSalesExporter/NEXT_STEPS_2026-04-15.md | 73 +- .../SAGE_SPAIN_EXPORT_2026-05-05.md | 234 +++++++ .../Services/FinanceReconciliationService.cs | 62 +- .../Services/ManualExcelImportService.cs | 213 ++++++ .../Tools/FinanceProbe/Program.cs | 645 +++++++++++++++++- .../Properties/launchSettings.json | 12 + .../ManualExcelImportServiceTests.cs | 42 ++ TrafagSalesExporter/check.xlsx | Bin 0 -> 17860 bytes TrafagSalesExporter/lastchange.md | 158 +++++ 12 files changed, 1583 insertions(+), 21 deletions(-) create mode 100644 TrafagSalesExporter/DE_Beispiel_Export_Daten.xlsx create mode 100644 TrafagSalesExporter/SAGE_SPAIN_EXPORT_2026-05-05.md create mode 100644 TrafagSalesExporter/Tools/FinanceProbe/Properties/launchSettings.json create mode 100644 TrafagSalesExporter/check.xlsx diff --git a/TrafagSalesExporter/DE_Beispiel_Export_Daten.xlsx b/TrafagSalesExporter/DE_Beispiel_Export_Daten.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..6bf32dd5f9f87ed397a052158bc19b35e0a1b61b GIT binary patch literal 10630 zcmeHtg;yNe_I2Y9jRp@+XdrlS2(G~)xCLw6f=jR_xP;&q+})kv?iPZ(yW5YM_ue-% znfd;L_inFMcU4#Iv#RQzefPcR90eH|SR4R601*HHkOK@4Q_OUs0DwqX000|+2(2Yz zW9?{c?Wm{XYHRGE^V-GAk}Lxj`c*mr`uYCEo;Dc5w82pqPbO#jvhHK7Qjy3Yro%LkMtisx=#`kk~qY`R}Pj8#5 zE)geFSI<6m>kEQtdrgg_bR43$EChObVJ1m{c)QvTC3evjA?YGQGh94@PirYZdektN zy6aI3H1oK7=i8U%)D|(h`tv7%Yh&1x=3qK^-Lo}DDcBGS_n&x9y4eck>dJJ6O7-@) zaWX9quj<&Q^glo^GD9Mp(HDl7;2T>wN#C&(R^$ht7ie}zTF|$8XnVM|z6uXHSGa9> zgKMV8sFnKGhun937NB4eQiWH{YCYoFDTtdXJ8=A+Y~hZ21)I2YiO8rSH6qeT4xhq` zVL7`eRaBmv6Xffa+uK*i)h2)Nf!M=3XAEH-R_yu-=Mry_#Bx1PaSI3rRMTt9X5q`iW5;mPtjl&`L`TR3Y?(k??Nz zy6>M~;0xRBCBIx{FABlJ;iar`E{4S2+I>U-(b&g|+ZC;JVmeJ-OkE~ONV~prYK{OE z*5!SZ>06={pEwaM2addcPl$w_O$@=~Pw>&~ey6c$cwGWDC9Hf<3@NMSOyB)7lH@%T zo3n!^_?cUFe>?%V*TK+quEe9)lKkR^P*vHC8(e0XYR^OAs%K=`b|#wCiv8fhD3{!; zM8S@A%{n5{L!Ex%ty#r>GLYih$%)ia*n8aP8}c%5@mZn%P7?k00E~JV0KgLo0KjX(RwpGfU$+lUE@xbQ zXlkY`rd5lgNYdsanra18?r(Zdqzmq}=#Cfx+qVfi7R{l=xl9&|6_1jyT@hjfR2V0i zRh{KpRgzUbhw80~db+r5HEDR<{ebx*%yKRvETq-t3Ya%wx`qcJ=5IAJxG(5b-Z&7=DI z%f1s~_Iq7#BTwJ4q< zAbqU*#DL6JJw20AxNdGjxhl*aQJPyg=E4ZlAyPF!ew|d^T31)-!{TH14&~JhbJCnV z)uoxNZFmJkGDV_4Qevl2_$pU7sgWsR!(wl*s}Pt^z~_fcb%WxmgVR6~1e+^=XvO># z%ZMjXBJpH)^z-fpnoA zGd?)DayJ&{bbnT=H zS#WefYUR*qs`TCwt%9wyY5`>2^jXgogq8Yg!0*v!xt}-0<5xi zt92X`5}It%9tC@tfSw%~j;~A59c2a6Mn*8s`3JU^Nq)nAW^EZYs&T^IszA5i-u$(riMyTh7>hoOj-`$Fo-pO<_1DjDE1DTzZoNzJc9GNKgRrfFu`>N>XX~@~U&X$_i+1|FU@TZSY|A5FIGdrfAs9r%gJM!&2{>%5{oTF7 z0~GW`2kd%*=w60Y9InmlSJVW7nK=}q=k?_2t+Z0g@CSnk!&zdy<^~x?SuN@wDmQIQM4 z3~2!ezX!yTEU;JNr8ix)!$w9zqw3L$qwrlnUF-ZQ!@&8G$$DSU)Y9`v|J`Na_j#~? z!T|uC1ONd3^Bw;d1_v`^V@C(3-#eDy6e3GQ$03Oa=-JA4CCJ=_5E%;PzUnwmC6^pg z1CGvd?Nkp>@I8S$=dz69pUewn31f|^f zna8cPf%*HzQR7_wl@v*Fc+B$@o0sPZmu={oiTV6bDQ2H7w)VLByhI5)w|Lz^ zzULV^SlH*%aJr(*N0iW-(xE$g}Xx@Oy3)? z1NlsYfIBH-P=wXvSoTi8b$lcuz8NR1MiDYW6kC>O#0<}Z_Id;y`>-00&CK1Vu7aM- z654;AKvGUckf3*$e(YVql8yv3dx1DqLu{pubgN}8+u=)M+~>(h|ALtcXE=1IBRA_l zwK=)`+D_3Iw~jsl)zqe^==0kOV6a%BT?PY`(lt1XrIbHX`x@k{=OFwer|4A=OD=2J zcK)kM74%+_V%`pO)s&T+ibGjO!gHx5zjh~MpA!(Ci*#(Nn078oVB_W|#TjCutG7)Y zNPII?leM=k6O<$-Yo$`lbW%+vl|Bol{fK-s#9mi(sMA!(G2npBaQcA&YDXxvxrGB# z*!+vGLJ{PB7(OL)qf8EIfi}u6@i*p~LNgcSO4d!~ie3^!r>wZ#k9q~vN;OU88U-co zk_*vcw!FP!pjb;PnV-YeB3UM`B63}OvLe|ot|H@(x<;Vv0iuc+UK`n}MM|QiEK|6L zmOZ$~!z8M&-#5?*&|qu!fh=S1=;w~T32M)VAh08Z=*av1j-0^F;91E}yxfnY9 z9{Ai@mN%iWU#XF|_C%5APN!+wuG(&i978DcKe1ihw9VC~t)z!e&u-C(tL*0mDdO^4 z8}j4wf(=W8!;;kXy5y4La}7=34Zbgz!bVZqZwf+F*%waWl^)E7&KT6DBa!Qf|P2%3Q12!B3T|{BsMHh-TI{9PP!dEgjksSe=c#$RG@5+IIdKOFUPdgE7r~ z=-`J|J8PS3&_y_1r;mmbcg0b(YK6x6U5S0LHI(y+e5!W4{iWDoqZM{}TWzrVny(mx z4k$mpfDi0zo9j5H6b4taO{Dk!2l@GNu^1Xu7c)2`73QU*^BC%<;UJVSl<2!`EI*T( z86&Lsq1|xrLur6bdxdoGx7><%A_^rMdf)BjNZ~7>ELp% z@qOc=U+Kij7z~-sQp@MKj{UJRJknhIHNLGjHohA!F5Yp48gdj!d+>PO%`sh-0a0sj#Xxk2ZQm+2(&IoOZ%xYhK!e9*d=}ka1;d+tg0EkXV9}?1 z_w$Z(j;H<6TEyWh3~p3BS)}|r)S+9H(vDCy$doyBgCsQ?)=SG4 z^8;M1p+*Xd2a?+dH8jswjWCvXd@;-TlLDFy+o3L3Z_Fi79ciU^eF(5kHR_19FZ3bYt;vz}8|08|-^JTC3B0r-;IznEzE^Z1ER*aULd!1li;Y2YnRH(8Upwct(m zldF6=&G-Nxvr~E`uvW)L_=ceg%J{k)y=5!#L2l?fkpjf#aCt9Nm!4R|1tu8(-LUi6 zkXqxy;FwmgZ>0!r(}d-1u>m@h0uXI}dt8~pCF8P7Gc?VP1^yE}Ymu4nBo8$TpWpRC zcCZY47OSz#!8aUQ?eAqT;i3F>7cgp{x>v71Ek{>v!pW`=M{fG9%Z{piY(_|*q8HNj zIvWbv(ra~jOnaf{ak6^TI*O?y4pKJmBUv@lh`n1m^1ffv^A3ExJ8FG6-bnlDqS4)Y zyLF`JdAA^+ADFEczq*mu>UMGQp6Tf{@aX9zTf|*{8B8GXjU{T^Upu3R4}!p+Q+D$u zzb>JZ>icAL8|JoC9%+6rbZQZc!^&;vn-yg!jc&N6RyPwn{*X>-#hyWo%voqshv8do zF0^BX5!H%gqpu7LUqVy~uJG4TW`4pXtD}<^D@m44X4?ZXLpJF;g1*Z{0|U-&?vsH% z6Q1Jv#xoyyu(eMEgwqr!dnUorNGX~=Du^WG`1ch~n6U4)YPh|EZf>KOSF$aSeF7lF}&LPbe#pW zJMFLbu-maEZH=YHK5gr%eP0>4^Pk_d8^VjPtB{rNRC78&+6@hVGzSMVUiU5OjbyY^8SAmX@EXF&DtkXA5|4B{|Bu`Y_U~#3<5%M(r4|M^NAITb ziv~hfGtQlZ}9>T2H+6NQU5#y0^wtK%?3w7FilV|J|U}|x1;9X`g{se?YO&xWz zyXu@d_y#P;3CbUxwKtH~t~&2HUv7Nuu+SM6_*q(3OR%bHFP)yePg62Ky;)Oz@_3|` z>%$kUBhJF?SY!*xk|0Bt;u*dSg)BVa5FQ%NW+GJfavG^rYDaRQLY9c>f zB}@GRf-Gg-^buV;AjOWxnklVjiN)O znH9XAi11Nz^GiGugpeAwR=cTix=%l*>5mt~maUriICcprXQTW`Jxd;9>fP3K4wA2S zcRag|HV{7*cj?GGv(Gd~0L#i~ehbh7p{SXanxrp1FOfSGnP6COqW*-c47FL10l=F{)< z#x2aum|N9n2sj02fU|LR-Ym?Wcc|WA6=no$7FGQ8NOA`$c1t zeQklS=!=tpxw3l%iBSG6`_-(K7FBYr%ltTo{Y<9X`_Jz_fT5R&6XHf{Zq5m)BFK{| zO4OZx3}Mx=6&t&OLTz$AT85bt2C?34IxNJPOLcTB z*;ZSKaXr4k*WB7?<{W5_G%ZnC1b5(SBK^4V+T(P)5SqnTxp(@Yl0UQSmA$Y%1!@-0 z#0w?#5!??R5~94@G(gQ2vP|*5(eHL{@%qe(pzCbHS7LwmQDn+x$>Ra&A71)$ zRMdNY*@x0cjSG3#fQeWIz9U^DKd{3jd!gV0aI|gw6?fK+Q7V=E=U~S6IM1( z3@ACgBK)e``X-NkKGTZ}UHsx3#&XlWcDq*SaD6@F^7wJX73Dt)y>Qw(;QTpK@Z(wR z(f%d$4vwyt#ty$n1XZnUl9+*y0^q0iRo;#4NNP+4j(1U@k5gGj<1IY;ScP6f-Bm^t zk9Wgw5jAo-EUu_$x6anK4(l65-=mps2jvAzV`?o1Sdde&CMcJO6_@T!rfTMmtFOmP z2^m_*gZ4l-*W?A~sZmqmz%YQYN_q7}Lo%=2*8{|!sQ`wcPhW&V;T)Dz)A`b3wz>&2 z$=oe_a>U{JWjy}eBg0TD>ipB$DN?$I?>J)=n{CP&IxBA$)FcBqQS;vDeqx(+!QRLe zCm{tFGcB&LD_2AZv;edv@Zg4eWg6H_H<$N6!}i*UoC^0`$Q#;B5=B8vMA4eBS4QVW z3cr8#rFxW%2FO7dFNl1@pyXf_n(`h?b!iOSME`_=Hi)$^OPDarZ?GEAhkJ*wM23<6 z{L|Oyk?z{I>49ri~<{lcFBg9cvnXVXu1KWoT>t#(#ju!8U(N>*9S1!F_I!}FFyY$ry z1!e_0r^evt_^f$u58?qoJ{+=nuj&S-TI9x+m?B{FRp)*g7?l$l(UbG-vWkaL9WbX11v|L#aq zZuQ>1?&VA(A)?f5Vkcdu&Vdc6>Z;j3pVA*T)HtK?AdAlhLjIlP-RIE>7~}1^EIx9q z2oH&;HE!9G>{bEirFuN48Bqo*YGY}$lC zOOIXMsCBn~h)wST^|*x#d;#+6L{9k(mm5{LymS7sr}UUJU3i?!E87`lteMrZE&q8blX4nV+=P62)pb6Bcx|OYCmffH~9QInPccJ}U?&`ygzL^M|e00dbvBa&74Q zzZrZ_;qyefvhC2nL{!7pBNM01^{!>vCG3DE?zm#Uq6UoBXrxbR$s&E|rMs7o79lb* zda?dHJWQDI*MOO)kd6VjAU;n9s2C`1q zLyq|7KtDDD)+$zq71gUJ+3LlSn_&BvB4;=#kkpQeNCLX_!k%iPQ*%D&Uc-Uzv zaqObwd3o^?f2<5;YJj^6I<^#*T_p1nf-3XH{fCu(HGdJ@MFu&&HY>cITHt6M^1})c z1kw2D1@1UrEP1*bDMQ8{u;m~lux5kM_RH%TKX!lTH+j^blUF19`GY*{1O;W5hRU;S zfs>Js>vDbr(IHd;hjnJe16XBX$5zXiha+f-aj(DbUO#q{u7MYTEViGk1!EQ?o5~ z?$4Gp^lsaWo|s!C&3KmH!9O-%K0b5u@3G7zM+#xF=a4q|S=2C}!{0_W9~A6uY#o?B z*w`EYCTh?5;QvLvpQp0W=-%S^zdZxYe<*S^cbx=Z52q7M@j)U82oy&MwDsKm`@_=OvO&q0*;!;S0rTj zwm=`kY_&3G4l|8nt4KyC5l=TZ>@LFWWQh>nAZ43JW?l7lGB9(&P-G5a0eU#v2tw`B zpPyR?*KIj52AV#lJ&>`?2Z=y#Wdz)(`6G$s5(# zD{(+8|Kq9duFw3`_DWkOF!Y>bxZlFdxm7mplD2zgzN`cI)}y zQt>dC=#ay(WT#h<%%F+ODx$u$$D5$& zsi77-jX;*UWfYdQ4GIw(VdIAyp!`Ne$PtAMYzy=YKD^cEZ2oqs-XsWuN2wu8pmnYC znMAQMk~M+kO{58fyV@?0h)ixx4bo5pf8^^59{tiZcxDmgF05zpaf}qLYrAfH+xuOi zzFW(=HmN53bO9=CNfp|zcAs*F_58F3OlR&)#B^t*@VmK(#dKwR5GY4C(={j-y}5^7 z)dB`Rj!$7`84KhJ%0uw?N}w-e0Z)x(h=`Dz^tGJwFp1Up`16~P^L)nRW3gU(LnE^P zLsW688%&1|V zpljyX+-HyA6*8qYBJs@|W@HT`eQT!j9<;TWG8x0i|MB6uB;a4uDHJs0^J3?pE8hMy zg#YRPq5e%l=C20+TATA9;BS50Gf)0hsq-uF*LsCNpiR$3K)+Nk{0jbS8uSk+0FZ(F zJNW;T6#doCueq^5EKQ;QzlZo+rtDWMzs6Jlu(E~qkI~g%4g9)K`@_H};qM0i*tPu% z{dEod2lR;KU(jDyw7**TtET>e2LQg20|0+h*k9p)ofiKLC!zWi{NIzLf(+dA=mG%9 P&%aO4B7RKs+u8pCbL;Y? literal 0 HcmV?d00001 diff --git a/TrafagSalesExporter/HANDOFF_2026-04-15.md b/TrafagSalesExporter/HANDOFF_2026-04-15.md index 1d6034a..17daff1 100644 --- a/TrafagSalesExporter/HANDOFF_2026-04-15.md +++ b/TrafagSalesExporter/HANDOFF_2026-04-15.md @@ -1,6 +1,123 @@ # TrafagSalesExporter Handoff -Stand: 2026-04-15 +Stand: 2026-05-05 + +## Nachtrag 2026-05-05 Aktueller Handoff FinanceProbe / Laenderabgleich + +Der aktuelle Arbeitsstand fuer den naechsten Einstieg ist der lokale FinanceProbe: + +```text +http://localhost:55417/finance +``` + +Der FinanceProbe wurde als Meeting-Ansicht erweitert: + +- `Meeting Ampel 2025` +- `Detail alle Laender` +- `Germany Excel sample check` +- `Spain CSV direct check` + +Ampel-Bedeutung: + +- Gruen: Ist/Soll passt rechnerisch gegen Referenz. +- Gelb: technische Daten vorhanden, aber Differenz oder fachliche Abgrenzung offen. +- Grau: keine belastbaren Ist-Daten im aktuellen Import. + +Wichtige Waehrungsregel fuer Management-Aussage: + +- Wenn die Quelle CHF liefert, kann CHF direkt als CHF gezeigt werden. +- Wenn die Quelle EUR/USD/GBP/INR usw. liefert, ist es Mandanten- bzw. Originalwaehrung. +- CHF-Ausweis braucht dann eine separate FX-Regel oder einen offiziell bestaetigten Kurs. + +### Spanien + +Vorhandene finale Kandidatendatei: + +```text +sagespain/v2/Spain_Sales_2025.csv +``` + +FinanceProbe liest diese Datei direkt. + +Aktueller Stand: + +- Zeilen: `4'341` +- Ist `SalesPriceValue`: `3'082'320.18` EUR +- Soll aus `check.xlsx`: `3'102'333.61` +- Differenz: `-20'013.43` +- Status: Gelb / Pruefen + +Technisch: + +- `ManualExcelImportService` kann jetzt semikolongetrennte CSV-Dateien lesen. +- Spanien-v2-CSV kann damit als `MANUAL_EXCEL` importiert werden. +- In der Detailtabelle wird Spanien nicht mehr als `Keine Daten` gezeigt, sondern als `Pruefen` mit dem v2-CSV-Wert. + +Offen fachlich: + +- Periodenlogik: `FechaFactura` vs. andere Datumsfelder +- Serien: `REG`, `LAT`, `PRO`, `REC` +- Behandlung von Gutschriften / `REC` +- offizielle Sage-Auswertung mit identischem Filter zur Sollzahl + +### Deutschland + +Vorhandenes Beispielfile: + +```text +DE_Beispiel_Export_Daten.xlsx +``` + +Wichtig: + +- Das File ist ein Beispielfile, keine finale DE-Jahresdatei. +- Es darf nicht als finale Ist-Zahl gegen die Jahresreferenz verwendet werden. + +Technischer Check: + +- relevante Spalte: `NettoPreisGesamtX` +- Mapping-Ziel: `SalesPriceValue` +- Betragszeilen: `2` +- Summe: `8'290.70` EUR +- Waehrung: `EUR` + +Interpretation: + +- Deutschland-Format ist technisch verstanden. +- Mapping funktioniert. +- Finale DE-Zahl fehlt noch. +- Fuer Abschluss/Meeting wird ein vollstaendiger DE-Jahresfile 2025 oder ein bestaetigter Importlauf benoetigt. + +### Geaenderte wichtige Dateien + +- `Tools/FinanceProbe/Program.cs` + - Management-Ampel + - Spanien-v2-CSV-Direktcheck + - Deutschland-Beispielfile-Check +- `Services/ManualExcelImportService.cs` + - CSV-Support fuer manuelle Quellen +- `Services/DatabaseSeedService.cs` + - deaktivierter Spanien-Standort als Seed/Fallback +- `TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs` + - Tests fuer CSV/Mapping +- `SAGE_SPAIN_EXPORT_2026-05-05.md` + - Spanien-Doku +- `lastchange.md` + - chronologischer Abschlussstand + +### Letzte Verifikation + +```text +dotnet build .\Tools\FinanceProbe\FinanceProbe.csproj --verbosity minimal --no-restore +dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal --no-restore +``` + +Ergebnis: + +- FinanceProbe Build erfolgreich +- Tests erfolgreich +- `50/50` Tests gruen +- FinanceProbe liefert `HTTP 200` ## Nachtrag 2026-04-29 Dashboard-Referenzcheck Net Sales 2025 diff --git a/TrafagSalesExporter/LLM_SYSTEM_GUIDE.md b/TrafagSalesExporter/LLM_SYSTEM_GUIDE.md index 2b1c194..79f944c 100644 --- a/TrafagSalesExporter/LLM_SYSTEM_GUIDE.md +++ b/TrafagSalesExporter/LLM_SYSTEM_GUIDE.md @@ -1,6 +1,50 @@ # TrafagSalesExporter LLM System Guide -Stand: 2026-04-17 +Stand: 2026-05-05 + +## Aktueller Projektstand 2026-05-05 + +Fuer den aktuellen Finance-/Laenderabgleich zuerst diese Dateien lesen: + +- [HANDOFF_2026-04-15.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/HANDOFF_2026-04-15.md) +- [NEXT_STEPS_2026-04-15.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md) +- [lastchange.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/lastchange.md) +- [SAGE_SPAIN_EXPORT_2026-05-05.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/SAGE_SPAIN_EXPORT_2026-05-05.md) + +Lokaler FinanceProbe: + +```text +http://localhost:55417/finance +``` + +Aktuelle FinanceProbe-Funktionen: + +- `Meeting Ampel 2025` fuer alle Laender aus `check.xlsx` +- `Detail alle Laender` +- `Spain CSV direct check` +- `Germany Excel sample check` + +Spanien: + +- Datei: `sagespain/v2/Spain_Sales_2025.csv` +- Ist: `3'082'320.18` EUR +- Soll: `3'102'333.61` +- Differenz: `-20'013.43` +- Status: Gelb / Pruefen +- Technisch lesbar, fachliche Differenz noch offen + +Deutschland: + +- Datei: `DE_Beispiel_Export_Daten.xlsx` +- Sample-Summe `NettoPreisGesamtX`: `8'290.70` EUR +- Nur Beispielfile, keine finale Jahreszahl +- Mapping technisch verstanden, finaler DE-Jahresfile fehlt + +Letzte Verifikation: + +- `dotnet build .\Tools\FinanceProbe\FinanceProbe.csproj --verbosity minimal --no-restore` +- `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal --no-restore` +- Ergebnis: Build OK, Tests `50/50`, FinanceProbe `HTTP 200` Diese Datei ist fuer andere LLMs gedacht, die das Projekt schnell verstehen und daraus Architekturtexte, Visualisierungen, Ablaufdiagramme oder UI-/Datenflussgrafiken erzeugen sollen. diff --git a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md index c198420..8c96f4f 100644 --- a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md +++ b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md @@ -1,6 +1,77 @@ # Next Steps -Stand: 2026-04-15 +Stand: 2026-05-05 + +## Nachtrag 2026-05-05 Abschlussstand FinanceProbe / Spanien / Deutschland + +Aktueller lokaler Testpunkt: + +```text +http://localhost:55417/finance +``` + +FinanceProbe enthaelt jetzt: + +- `Meeting Ampel 2025` fuer alle Laender aus `check.xlsx` +- Ampel: + - Gruen: rechnerisch passend + - Gelb: Differenz oder fachliche Abgrenzung offen + - Grau: keine belastbaren Ist-Daten +- `Detail alle Laender` +- `Spain CSV direct check` +- `Germany Excel sample check` + +Spanien: + +- finale v2-Datei liegt unter `sagespain/v2/Spain_Sales_2025.csv` +- Zeilen: `4'341` +- Ist `SalesPriceValue`: `3'082'320.18` EUR +- Soll aus `check.xlsx`: `3'102'333.61` +- Differenz: `-20'013.43` +- Status: Gelb / Pruefen +- Export technisch lesbar, Differenz fachlich mit Spanien/Finance klaeren + +Deutschland: + +- Beispielfile liegt im Projektordner: + +```text +DE_Beispiel_Export_Daten.xlsx +``` + +- relevante Spalte: `NettoPreisGesamtX` +- Mapping-Ziel: `SalesPriceValue` +- Betragszeilen: `2` +- Summe: `8'290.70` EUR +- das ist nur ein Sample, keine finale DE-Jahreszahl +- Deutschland bleibt fuer die finale Ampel offen/grau, bis ein vollstaendiger DE-Jahresfile 2025 oder ein bestaetigter Importlauf vorliegt + +Offen fuer das Finance-Meeting / danach: + +1. Spanien Differenz `-20'013.43` klaeren: + - Periodendatum + - Serien `REG`, `LAT`, `PRO`, `REC` + - Gutschriften / `REC` + - offizielle Sage-Auswertung mit identischem Filter +2. Deutschland finalen Jahresfile 2025 anfordern oder Importlauf mit finaler Datei ausfuehren. +3. Fuer Laender mit Grau pruefen, ob Exportdaten fehlen oder Standort deaktiviert/ohne Datei ist. +4. Fuer CHF-Aussage beachten: + - CHF nur direkt, wenn Quelle CHF liefert + - sonst Mandanten-/Originalwaehrung und separate FX-Regel noetig + +Letzte Verifikation: + +```text +dotnet build .\Tools\FinanceProbe\FinanceProbe.csproj --verbosity minimal --no-restore +dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal --no-restore +``` + +Ergebnis: + +- FinanceProbe Build erfolgreich +- Tests erfolgreich +- `50/50` Tests gruen +- Web UI `HTTP 200` ## Nachtrag 2026-04-29 Dashboard-Referenzcheck diff --git a/TrafagSalesExporter/SAGE_SPAIN_EXPORT_2026-05-05.md b/TrafagSalesExporter/SAGE_SPAIN_EXPORT_2026-05-05.md new file mode 100644 index 0000000..2d37161 --- /dev/null +++ b/TrafagSalesExporter/SAGE_SPAIN_EXPORT_2026-05-05.md @@ -0,0 +1,234 @@ +# Sage Spain Export + +Stand: 2026-05-05 + +## Aktueller Kurzstatus + +- Spanien-v2-Export ist technisch lauffaehig und im Testprogramm sichtbar. +- Datei: `sagespain/v2/Spain_Sales_2025.csv` +- Ist 2025: `3'082'320.18` EUR +- Soll aus `check.xlsx`: `3'102'333.61` +- Differenz: `-20'013.43` +- Status FinanceProbe: Gelb / Pruefen +- Finale Aussage: technisch importierbar, aber fachlich noch nicht abgestimmt. + +FinanceProbe lokal: + +```text +http://localhost:55417/finance +``` + +Relevante Abschnitte: + +- `Meeting Ampel 2025` +- `Detail alle Laender` +- `Spain CSV direct check` + +Wichtig: + +- Spanien wird in der Detailtabelle nicht mehr als `Keine Daten` gezeigt, wenn `Spain_Sales_2025.csv` vorhanden ist. +- Stattdessen wird der v2-CSV-Wert mit Status `Pruefen` angezeigt. +- Die CSV-Datei kann spaeter als `MANUAL_EXCEL`-Quelle importiert werden. + +## Ziel + +Spanien soll Verkaufsdaten aus `Sage 200c` liefern koennen, damit der Standort in `TrafagSalesExporter` wie die anderen Laender in die zentrale Auswertung und Finance-Abgrenzung aufgenommen werden kann. + +## Systemstand Spanien + +Ermittelt mit `scripts/Get-SageSqlEnvironment.ps1`. + +- Windows Server: `Microsoft Windows Server 2019 Standard`, Build `17763` +- Server: `WIN-4BJQJ9S1PVJ` +- Sage: `Sage 200c` +- Sage-Version: `2026.56.000` +- SQL Server: `Microsoft SQL Server 2019 Standard Edition (64-bit)` +- SQL Build: `15.0.2155.2` +- SQL Full Version: `Microsoft SQL Server 2019 (RTM-GDR) (KB5068405) - 15.0.2155.2 (X64)` +- SQL Instance: Default Instance `MSSQLSERVER`, erreichbar als `localhost` +- Datenbank: `Sage` +- Collation: `Latin1_General_CI_AI` + +## Discovery + +Ermittelt mit `scripts/Export-SageSqlCsv.ps1`. + +Relevante Kandidaten: + +- `dbo.CabeceraAlbaranCliente` +- `dbo.LineasAlbaranCliente` +- `dbo.EstadisVenta` +- `dbo.EstadisVentaTallas` +- `dbo.FacturasTB` +- `dbo.MovimientosFacturas` +- `dbo.Vis_RTDV_EfectosFactura` + +Beobachtung: + +- `CabeceraAlbaranCliente` ist der Verkaufs-/Albaran-Belegkopf. +- `LineasAlbaranCliente` enthaelt die Verkaufspositionen. +- `EstadisVenta` enthaelt Statistikdaten, aber im gelieferten Export keine 2025-Zeilen. +- `FacturasTB` und `MovimientosFacturas` wirken eher Finanz-/Steuer-/Buchungsdaten und enthalten gemischte Bewegungen. + +## Export v2 + +Finaler Export-Kandidat wurde mit `SageSpainFinalExportPackage.zip` bzw. danach `v2.zip` erstellt. + +Script: + +- `scripts/Export-SageSpainSalesCsv.ps1` + +Output von Spanien: + +- `sagespain/v2/Spain_Sales_2025.csv` +- `sagespain/v2/Spain_Sales_2025_summary.txt` + +Quelle: + +- Header: `dbo.CabeceraAlbaranCliente` +- Lines: `dbo.LineasAlbaranCliente` +- Join: + - `CodigoEmpresa` + - `EjercicioAlbaran` + - `SerieAlbaran` + - `NumeroAlbaran` + +Filter: + +- `CabeceraAlbaranCliente.FechaFactura >= 2025-01-01` +- `CabeceraAlbaranCliente.FechaFactura < 2026-01-01` + +Export-Spalten sind bereits auf das Zielmodell der App ausgerichtet, u. a.: + +- `TSC` +- `Land` +- `InvoiceNumber` +- `PositionOnInvoice` +- `Material` +- `Name` +- `ProductGroup` +- `Quantity` +- `CustomerNumber` +- `CustomerName` +- `CustomerCountry` +- `StandardCost` +- `StandardCostCurrency` +- `PurchaseOrderNumber` +- `SalesPriceValue` +- `SalesCurrency` +- `DocumentCurrency` +- `CompanyCurrency` +- `InvoiceDate` +- `DocumentType` + +## Ergebnis Export v2 + +Aus `Spain_Sales_2025_summary.txt`: + +- Zeilen: `4'341` +- `SalesPriceValue` Summe: `3'082'320.18` +- `SalesPriceValue` = `LineasAlbaranCliente.ImporteNeto` +- Waehrung: `EUR` + +Aufteilung: + +- Invoices: `3'140'921.50` +- Credit Notes / REC: `-58'601.32` +- Total: `3'082'320.18` + +Nach Serie: + +- `REG`: `2'407'451.30` +- `LAT`: `480'199.20` +- `PRO`: `253'271.00` +- `REC`: `-58'601.32` + +## Abgleich gegen check.xlsx + +Sollwert fuer Spanien aus `check.xlsx`: + +- `3'102'333.61` + +Aktueller Export v2: + +- `3'082'320.18` + +Differenz: + +- `-20'013.43` + +Fruehere breite Positionssumme aus `LineasAlbaranCliente.ImporteNeto` ohne Join-/Rechnungsdatumsfilter lag bei: + +- `3'094'474.32` +- Differenz zur Sollzahl: `-7'859.29` + +## Offene fachliche Klaerung + +Spanien / Finance muss noch klaeren, woher die Differenz kommt. + +Zu pruefen: + +1. Ist `FechaFactura` das korrekte Periodendatum? +2. Oder muss `FechaAlbaran` bzw. `FechaRegistro` verwendet werden? +3. Muessen Zeilen ohne `EjercicioFactura = 2025` in die Sollzahl? +4. Sind alle Serien `REG`, `LAT`, `PRO`, `REC` enthalten? +5. Muessen `REC`-Abos negativ abgezogen werden? +6. Gibt es weitere Serien oder Dokumenttypen ausserhalb `CabeceraAlbaranCliente` / `LineasAlbaranCliente`? +7. Gibt es eine offizielle Sage-Auswertung, die `3'102'333.61` erzeugt und deren Filter genannt werden koennen? + +## Einbau ins Hauptprogramm + +Umgesetzt: + +- `ManualExcelImportService` kann jetzt neben `.xlsx` auch semikolongetrennte `.csv`-Dateien lesen. +- Der CSV-Reader unterstuetzt quotierte Felder und mehrzeilige Texte. +- Das Spanien-v2-CSV ist damit als `MANUAL_EXCEL`-Quelle importierbar. +- `Tools/FinanceProbe` hat einen direkten `Spain CSV direct check`. + - Die Probe sucht automatisch nach `Spain_Sales_2025.csv`, bevorzugt unter `sagespain/v2`. + - Angezeigt werden Zeilen, `SalesPriceValue`, Sollwert `3'102'333.61`, Differenz, Aufteilung nach `DocumentType` und `InvoiceSeries`. + - Spanien wird in der FinanceProbe-Detailtabelle mit dem v2-CSV-Wert angezeigt, nicht mehr als `Keine Daten`. + - In der Management-Ampel bleibt Spanien gelb, bis die Differenz fachlich geklaert ist. +- `DatabaseSeedService` stellt einen deaktivierten Spanien-Standort bereit, falls noch kein Spanien-Standort existiert: + - `TSC = TRES` + - `Land = Spanien` + - `SourceSystem = MANUAL_EXCEL` + - `IsActive = false` + +Wichtig: + +- Das Programm setzt den Dateipfad nicht automatisch, weil der Pfad pro Umgebung unterschiedlich ist. +- In der UI muss beim Standort Spanien die Datei `Spain_Sales_2025.csv` hinterlegt werden. +- Danach kann Spanien wie ein manueller Standort exportiert werden; die Daten landen in `CentralSalesRecords`. + +## Naechster Schritt + +1. App starten. +2. `Standorte` oeffnen. +3. Spanien pruefen bzw. aktivieren. +4. `SourceSystem = MANUAL_EXCEL`. +5. `Spain_Sales_2025.csv` als manuelle Datei hinterlegen. +6. Standort Spanien exportieren. +7. Finance-Probe / Dashboard erneut pruefen. +8. Differenz zu `check.xlsx` fachlich mit Spanien/Finance klaeren. + +## Abgrenzung Deutschland + +Am selben Tag wurde auch ein Deutschland-Beispielfile gefunden: + +```text +DE_Beispiel_Export_Daten.xlsx +``` + +Dieses File ist nicht Teil des Spanien-Exports, aber im FinanceProbe als separater `Germany Excel sample check` sichtbar. + +Deutschland-Sample: + +- relevante Spalte: `NettoPreisGesamtX` +- Summe: `8'290.70` EUR +- Betragszeilen: `2` +- Bewertung: technisch lesbar, aber kein finaler DE-Jahresfile + +Fuer die Gesamtampel heisst das: + +- Spanien: technische v2-Datei vorhanden, Differenz offen +- Deutschland: Format verstanden, aber finale Jahresdatei fehlt diff --git a/TrafagSalesExporter/Services/FinanceReconciliationService.cs b/TrafagSalesExporter/Services/FinanceReconciliationService.cs index 5fbbbc3..e10158d 100644 --- a/TrafagSalesExporter/Services/FinanceReconciliationService.cs +++ b/TrafagSalesExporter/Services/FinanceReconciliationService.cs @@ -33,6 +33,19 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService new("US", "Traga US", 3896728m, 3749865m) ]; + private static readonly IReadOnlyDictionary BudgetRatesToChf = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["CHF"] = 1m, + ["USD"] = 0.85m, + ["EUR"] = 0.95m, + ["GBP"] = 1.13m, + ["CNY"] = 1m / 8.50m, + ["INR"] = 1m / 90.91m, + ["CZK"] = 1m / 25.64m, + ["PLN"] = 0.22m, + ["JPY"] = 1m / 156.25m + }; + public FinanceReconciliationService(IDbContextFactory dbFactory) { _dbFactory = dbFactory; @@ -84,11 +97,10 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService { groupedActuals.TryGetValue(reference.Key, out var actual); var referenceValue = reference.PowerBiValue ?? reference.LocalCurrencyValue; - var selected = actual is null || !referenceValue.HasValue - ? actual?.Candidates.FirstOrDefault() - : actual.Candidates - .OrderBy(candidate => Math.Abs(candidate.Value - referenceValue.Value)) - .FirstOrDefault(); + var selected = actual?.Candidates + .OrderByDescending(candidate => candidate.Key == "NetDocumentLocalCurrency") + .ThenByDescending(candidate => candidate.Key == "SalesPriceValue") + .FirstOrDefault(); var difference = selected is null || !referenceValue.HasValue ? (decimal?)null : selected.Value - referenceValue.Value; var intercompanyAdjustedDifference = selected is null || !referenceValue.HasValue ? (decimal?)null @@ -108,8 +120,8 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService Currencies = actual?.Currencies ?? string.Empty, ValueField = selected?.Label ?? string.Empty, ActualCurrency = selected?.Currency ?? string.Empty, - ReferenceSource = reference.PowerBiValue.HasValue ? "Power BI" : "LC", - ReferenceCurrency = reference.PowerBiValue.HasValue ? "Power BI Original" : "LC", + ReferenceSource = "check.xlsx Soll", + ReferenceCurrency = reference.PowerBiValue.HasValue ? "Sollwert" : "LC", Status = BuildReferenceStatus(difference), Candidates = actual?.Candidates.Select(candidate => new NetSalesCandidateRow { @@ -161,15 +173,24 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService if (netDocumentLocalCurrency != 0m) candidates.Add(new( "NetDocumentLocalCurrency", - "DocTotal - VatSum", + "Nettofakturawert Hauswaehrung", ResolveCurrencyLabel(rowList.Select(row => row.CompanyCurrency)), netDocumentLocalCurrency, documentRows.Where(IsLikelyIntercompanyCustomer).Sum(row => row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency))); + var budgetChf = documentRows.Sum(row => ConvertHouseCurrencyNetToBudgetChf(row, row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency)); + if (budgetChf != 0m) + candidates.Add(new( + "NetDocumentLocalCurrencyBudgetChf", + "Nettofakturawert Hauswaehrung -> CHF Budget 2025", + "CHF", + budgetChf, + documentRows.Where(IsLikelyIntercompanyCustomer).Sum(row => ConvertHouseCurrencyNetToBudgetChf(row, row.DocumentTotalLocalCurrency - row.VatSumLocalCurrency)))); + return new NetSalesActual { RowCount = rowList.Count, - Currencies = string.Join(", ", rowList.Select(row => row.SalesCurrency) + Currencies = string.Join(", ", rowList.Select(row => string.IsNullOrWhiteSpace(row.CompanyCurrency) ? row.SalesCurrency : row.CompanyCurrency) .Where(x => !string.IsNullOrWhiteSpace(x)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)), @@ -177,6 +198,14 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService }; } + private static decimal ConvertHouseCurrencyNetToBudgetChf(NetSalesActualSourceRow row, decimal value) + { + var currency = (row.CompanyCurrency ?? string.Empty).Trim().ToUpperInvariant(); + return BudgetRatesToChf.TryGetValue(currency, out var rate) + ? value * rate + : 0m; + } + private static string ResolveCurrencyLabel(IEnumerable currencies) { var distinct = currencies @@ -202,6 +231,21 @@ public sealed class FinanceReconciliationService : IFinanceReconciliationService if (string.IsNullOrWhiteSpace(customerNumber) && string.IsNullOrWhiteSpace(customerName)) return false; + var normalizedCustomerName = customerName + .Replace("ä", "ae", StringComparison.OrdinalIgnoreCase) + .Replace("ö", "oe", StringComparison.OrdinalIgnoreCase) + .Replace("ü", "ue", StringComparison.OrdinalIgnoreCase) + .ToUpperInvariant(); + + if (normalizedCustomerName.Contains("TRAFAG", StringComparison.OrdinalIgnoreCase) || + normalizedCustomerName.Contains("MAGNETIC SENSE", StringComparison.OrdinalIgnoreCase) || + normalizedCustomerName.Contains("MAGNETS SENSE", StringComparison.OrdinalIgnoreCase) || + normalizedCustomerName.Contains("GESELLSCHAFT FUER SENSORIK", StringComparison.OrdinalIgnoreCase) || + normalizedCustomerName.Contains("GESELLSCHAFT FUR SENSORIK", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + if (row.Tsc.Equals("TRIT", StringComparison.OrdinalIgnoreCase)) { return customerNumber.Equals("C_IT01_0306794", StringComparison.OrdinalIgnoreCase) || diff --git a/TrafagSalesExporter/Services/ManualExcelImportService.cs b/TrafagSalesExporter/Services/ManualExcelImportService.cs index 4087497..058e0b3 100644 --- a/TrafagSalesExporter/Services/ManualExcelImportService.cs +++ b/TrafagSalesExporter/Services/ManualExcelImportService.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Reflection; using ClosedXML.Excel; using Microsoft.EntityFrameworkCore; +using Microsoft.VisualBasic.FileIO; using TrafagSalesExporter.Data; using TrafagSalesExporter.Models; @@ -91,6 +92,9 @@ public class ManualExcelImportService : IManualExcelImportService private static List ReadSalesRecords(string filePath, Site site, IReadOnlyList mappings) { + if (string.Equals(Path.GetExtension(filePath), ".csv", StringComparison.OrdinalIgnoreCase)) + return ReadCsvSalesRecords(filePath, site, mappings); + using var workbook = new XLWorkbook(filePath); var worksheet = workbook.Worksheets.FirstOrDefault() ?? throw new InvalidOperationException("Die Excel-Datei enthaelt kein Arbeitsblatt."); @@ -109,6 +113,141 @@ public class ManualExcelImportService : IManualExcelImportService : ReadDefaultRows(usedRange, headerRow, site); } + private static List ReadCsvSalesRecords(string filePath, Site site, IReadOnlyList mappings) + { + using var parser = new TextFieldParser(filePath) + { + TextFieldType = FieldType.Delimited, + HasFieldsEnclosedInQuotes = true, + TrimWhiteSpace = false + }; + parser.SetDelimiters(";"); + + var header = parser.ReadFields() + ?? throw new InvalidOperationException("Die CSV-Datei enthaelt keine Kopfzeile."); + + var activeMappings = mappings + .Where(m => m.IsActive && !string.IsNullOrWhiteSpace(m.TargetField) && !string.IsNullOrWhiteSpace(m.SourceHeader)) + .OrderBy(m => m.SortOrder) + .ThenBy(m => m.Id) + .ToList(); + + return activeMappings.Count > 0 + ? ReadMappedCsvRows(parser, header, site, activeMappings) + : ReadDefaultCsvRows(parser, header, site); + } + + private static List ReadDefaultCsvRows(TextFieldParser parser, string[] header, Site site) + { + var headerIndexes = BuildHeaderIndexMap(header); + var rows = new List(); + + while (!parser.EndOfData) + { + var fields = parser.ReadFields(); + if (fields is null || IsCsvRowEmpty(fields)) + continue; + + rows.Add(new SalesRecord + { + ExtractionDate = ReadDate(headerIndexes, fields, nameof(SalesRecord.ExtractionDate)) ?? DateTime.UtcNow, + Tsc = ReadString(headerIndexes, fields, nameof(SalesRecord.Tsc), site.TSC), + DocumentEntry = (int)Math.Round(ReadDecimal(headerIndexes, fields, nameof(SalesRecord.DocumentEntry))), + InvoiceNumber = ReadString(headerIndexes, fields, nameof(SalesRecord.InvoiceNumber)), + PositionOnInvoice = (int)Math.Round(ReadDecimal(headerIndexes, fields, nameof(SalesRecord.PositionOnInvoice))), + Material = ReadString(headerIndexes, fields, nameof(SalesRecord.Material)), + Name = ReadString(headerIndexes, fields, nameof(SalesRecord.Name)), + ProductGroup = ReadString(headerIndexes, fields, nameof(SalesRecord.ProductGroup)), + Quantity = ReadDecimal(headerIndexes, fields, nameof(SalesRecord.Quantity)), + SupplierNumber = ReadString(headerIndexes, fields, nameof(SalesRecord.SupplierNumber)), + SupplierName = ReadString(headerIndexes, fields, nameof(SalesRecord.SupplierName)), + SupplierCountry = ReadString(headerIndexes, fields, nameof(SalesRecord.SupplierCountry)), + CustomerNumber = ReadString(headerIndexes, fields, nameof(SalesRecord.CustomerNumber)), + CustomerName = ReadString(headerIndexes, fields, nameof(SalesRecord.CustomerName)), + CustomerCountry = ReadString(headerIndexes, fields, nameof(SalesRecord.CustomerCountry)), + CustomerIndustry = ReadString(headerIndexes, fields, nameof(SalesRecord.CustomerIndustry)), + StandardCost = ReadDecimal(headerIndexes, fields, nameof(SalesRecord.StandardCost)), + StandardCostCurrency = ReadString(headerIndexes, fields, nameof(SalesRecord.StandardCostCurrency)), + PurchaseOrderNumber = ReadString(headerIndexes, fields, nameof(SalesRecord.PurchaseOrderNumber)), + SalesPriceValue = ReadDecimal(headerIndexes, fields, nameof(SalesRecord.SalesPriceValue)), + SalesCurrency = ReadString(headerIndexes, fields, nameof(SalesRecord.SalesCurrency)), + DocumentCurrency = ReadString(headerIndexes, fields, nameof(SalesRecord.DocumentCurrency)), + DocumentTotalForeignCurrency = ReadDecimal(headerIndexes, fields, nameof(SalesRecord.DocumentTotalForeignCurrency)), + DocumentTotalLocalCurrency = ReadDecimal(headerIndexes, fields, nameof(SalesRecord.DocumentTotalLocalCurrency)), + VatSumForeignCurrency = ReadDecimal(headerIndexes, fields, nameof(SalesRecord.VatSumForeignCurrency)), + VatSumLocalCurrency = ReadDecimal(headerIndexes, fields, nameof(SalesRecord.VatSumLocalCurrency)), + DocumentRate = ReadDecimal(headerIndexes, fields, nameof(SalesRecord.DocumentRate)), + CompanyCurrency = ReadString(headerIndexes, fields, nameof(SalesRecord.CompanyCurrency)), + Incoterms2020 = ReadString(headerIndexes, fields, nameof(SalesRecord.Incoterms2020)), + SalesResponsibleEmployee = ReadString(headerIndexes, fields, nameof(SalesRecord.SalesResponsibleEmployee)), + InvoiceDate = ReadDate(headerIndexes, fields, nameof(SalesRecord.InvoiceDate)), + OrderDate = ReadDate(headerIndexes, fields, nameof(SalesRecord.OrderDate)), + Land = ReadString(headerIndexes, fields, nameof(SalesRecord.Land), site.Land), + DocumentType = ReadString(headerIndexes, fields, nameof(SalesRecord.DocumentType)) + }); + } + + return rows; + } + + private static List ReadMappedCsvRows( + TextFieldParser parser, + string[] header, + Site site, + IReadOnlyList mappings) + { + var headerIndexes = BuildRawHeaderIndexMap(header); + foreach (var mapping in mappings.Where(m => m.IsRequired)) + { + if (mapping.SourceHeader.Trim().StartsWith('=')) + continue; + + if (!TryResolveHeaderIndex(headerIndexes, mapping.SourceHeader, out _)) + throw new InvalidOperationException($"Pflichtspalte '{mapping.SourceHeader}' fuer Zielfeld '{mapping.TargetField}' fehlt."); + } + + var rows = new List(); + while (!parser.EndOfData) + { + var fields = parser.ReadFields(); + if (fields is null || IsCsvRowEmpty(fields)) + continue; + + var record = new SalesRecord + { + ExtractionDate = DateTime.UtcNow, + Tsc = site.TSC, + Land = site.Land, + DocumentType = "Manual Excel" + }; + + foreach (var mapping in mappings) + { + if (!SalesRecordProperties.TryGetValue(mapping.TargetField, out var property)) + continue; + + var value = ReadMappedValue(headerIndexes, fields, mapping.SourceHeader); + SetPropertyValue(record, property, value); + } + + if (record.ExtractionDate == default) + record.ExtractionDate = DateTime.UtcNow; + if (string.IsNullOrWhiteSpace(record.Tsc)) + record.Tsc = site.TSC; + if (string.IsNullOrWhiteSpace(record.Land)) + record.Land = site.Land; + if (string.IsNullOrWhiteSpace(record.DocumentType)) + record.DocumentType = "Manual Excel"; + + if (!IsMeaningfulMappedRecord(record)) + continue; + + rows.Add(record); + } + + return rows; + } + private static List ReadDefaultRows(IXLRange usedRange, IXLRangeRow headerRow, Site site) { var headerIndexes = BuildHeaderIndexMap(headerRow); @@ -238,6 +377,26 @@ public class ManualExcelImportService : IManualExcelImportService return result; } + private static Dictionary BuildHeaderIndexMap(string[] header) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + for (var i = 0; i < header.Length; i++) + { + var normalizedHeader = NormalizeHeader(header[i]); + if (string.IsNullOrWhiteSpace(normalizedHeader)) + continue; + + if (HeaderMap.TryGetValue(normalizedHeader, out var targetField)) + result[targetField] = i; + } + + if (!result.ContainsKey(nameof(SalesRecord.InvoiceNumber))) + throw new InvalidOperationException("Die CSV-Datei hat nicht das erwartete Exportformat. Spalte 'Invoice Number' fehlt."); + + return result; + } + private static Dictionary BuildRawHeaderIndexMap(IXLRangeRow headerRow) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -255,6 +414,23 @@ public class ManualExcelImportService : IManualExcelImportService return result; } + private static Dictionary BuildRawHeaderIndexMap(string[] header) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + for (var i = 0; i < header.Length; i++) + { + var value = header[i].Trim(); + if (string.IsNullOrWhiteSpace(value)) + continue; + + result[value] = i; + result[NormalizeHeader(value)] = i; + } + + return result; + } + private static bool TryResolveHeaderIndex(Dictionary headerIndexes, string sourceHeader, out int index) { var trimmed = sourceHeader.Trim(); @@ -273,9 +449,23 @@ public class ManualExcelImportService : IManualExcelImportService : null; } + private static object? ReadMappedValue(Dictionary headerIndexes, string[] fields, string sourceHeader) + { + var trimmed = sourceHeader.Trim(); + if (trimmed.StartsWith('=')) + return trimmed[1..]; + + return TryResolveHeaderIndex(headerIndexes, trimmed, out var index) && index < fields.Length + ? fields[index].Trim() + : null; + } + private static bool IsRowEmpty(IXLRangeRow row) => row.CellsUsed().All(cell => string.IsNullOrWhiteSpace(cell.GetFormattedString())); + private static bool IsCsvRowEmpty(string[] fields) + => fields.All(string.IsNullOrWhiteSpace); + private static string ReadString(Dictionary headerIndexes, IXLRangeRow row, string fieldName, string fallback = "") { if (!headerIndexes.TryGetValue(fieldName, out var index)) @@ -285,6 +475,15 @@ public class ManualExcelImportService : IManualExcelImportService return string.IsNullOrWhiteSpace(value) ? fallback : value; } + private static string ReadString(Dictionary headerIndexes, string[] fields, string fieldName, string fallback = "") + { + if (!headerIndexes.TryGetValue(fieldName, out var index) || index >= fields.Length) + return fallback; + + var value = fields[index].Trim(); + return string.IsNullOrWhiteSpace(value) ? fallback : value; + } + private static decimal ReadDecimal(Dictionary headerIndexes, IXLRangeRow row, string fieldName) { if (!headerIndexes.TryGetValue(fieldName, out var index)) @@ -299,6 +498,13 @@ public class ManualExcelImportService : IManualExcelImportService return ParseDecimal(cell.GetFormattedString().Trim()); } + private static decimal ReadDecimal(Dictionary headerIndexes, string[] fields, string fieldName) + { + return !headerIndexes.TryGetValue(fieldName, out var index) || index >= fields.Length + ? 0m + : ParseDecimal(fields[index].Trim()); + } + private static DateTime? ReadDate(Dictionary headerIndexes, IXLRangeRow row, string fieldName) { if (!headerIndexes.TryGetValue(fieldName, out var index)) @@ -311,6 +517,13 @@ public class ManualExcelImportService : IManualExcelImportService return ParseDate(cell.GetFormattedString().Trim()); } + private static DateTime? ReadDate(Dictionary headerIndexes, string[] fields, string fieldName) + { + return !headerIndexes.TryGetValue(fieldName, out var index) || index >= fields.Length + ? null + : ParseDate(fields[index].Trim()); + } + private static void SetPropertyValue(SalesRecord record, PropertyInfo property, object? value) { try diff --git a/TrafagSalesExporter/Tools/FinanceProbe/Program.cs b/TrafagSalesExporter/Tools/FinanceProbe/Program.cs index 506b4fb..18618f1 100644 --- a/TrafagSalesExporter/Tools/FinanceProbe/Program.cs +++ b/TrafagSalesExporter/Tools/FinanceProbe/Program.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Net; using ClosedXML.Excel; using Microsoft.EntityFrameworkCore; +using Microsoft.VisualBasic.FileIO; using TrafagSalesExporter.Data; using TrafagSalesExporter.Services; @@ -19,7 +20,9 @@ app.MapGet("/finance", async (IFinanceReconciliationService finance) => { var rows = await finance.BuildNetSalesReferenceRowsAsync(2025); var excelReferences = LoadCheckedExcelReferences(ResolveCheckedExcelPath()); - return Results.Content(BuildPage(rows, databasePath, excelReferences), "text/html; charset=utf-8"); + var spainCsv = LoadSpainSalesCsvProbe(ResolveSpainSalesCsvPath()); + var germanySample = LoadGermanyExcelProbe(ResolveGermanySamplePath()); + return Results.Content(BuildPage(rows, databasePath, excelReferences, spainCsv, germanySample), "text/html; charset=utf-8"); }); app.Run(); @@ -63,6 +66,58 @@ static string? ResolveCheckedExcelPath() return null; } +static string? ResolveSpainSalesCsvPath() +{ + foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory }) + { + var directory = new DirectoryInfo(start); + while (directory is not null) + { + var directCandidate = Path.Combine(directory.FullName, "sagespain", "v2", "Spain_Sales_2025.csv"); + if (File.Exists(directCandidate)) + return directCandidate; + + var recursiveCandidate = Directory + .EnumerateFiles(directory.FullName, "Spain_Sales_2025.csv", System.IO.SearchOption.AllDirectories) + .OrderByDescending(File.GetLastWriteTimeUtc) + .FirstOrDefault(); + + if (!string.IsNullOrWhiteSpace(recursiveCandidate)) + return recursiveCandidate; + + directory = directory.Parent; + } + } + + return null; +} + +static string? ResolveGermanySamplePath() +{ + foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory }) + { + var directory = new DirectoryInfo(start); + while (directory is not null) + { + var directCandidate = Path.Combine(directory.FullName, "DE_Beispiel_Export_Daten.xlsx"); + if (File.Exists(directCandidate)) + return directCandidate; + + var recursiveCandidate = Directory + .EnumerateFiles(directory.FullName, "DE_Beispiel_Export_Daten.xlsx", System.IO.SearchOption.AllDirectories) + .OrderByDescending(File.GetLastWriteTimeUtc) + .FirstOrDefault(); + + if (!string.IsNullOrWhiteSpace(recursiveCandidate)) + return recursiveCandidate; + + directory = directory.Parent; + } + } + + return null; +} + static Dictionary LoadCheckedExcelReferences(string? path) { if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) @@ -99,16 +154,199 @@ static decimal? ReadNullableDecimal(IXLCell cell) return cell.TryGetValue(out var value) ? value : null; } +static GermanyExcelProbe? LoadGermanyExcelProbe(string? path) +{ + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + return null; + + using var workbook = new XLWorkbook(path); + var worksheet = workbook.Worksheets.FirstOrDefault(); + var usedRange = worksheet?.RangeUsed(); + if (worksheet is null || usedRange is null) + return null; + + var headerRow = usedRange.FirstRow(); + var headers = headerRow.CellsUsed() + .ToDictionary(cell => cell.GetString().Trim(), cell => cell.Address.ColumnNumber, StringComparer.OrdinalIgnoreCase); + + if (!headers.TryGetValue("NettoPreisGesamtX", out var amountColumn)) + return null; + + headers.TryGetValue("Währung", out var currencyColumn); + headers.TryGetValue("Belegdatum-Rechnung", out var invoiceDateColumn); + + var total = 0m; + var rowsWithAmount = 0; + var rowsIn2025 = 0; + var totalIn2025 = 0m; + var currencies = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var row in usedRange.RowsUsed().Skip(1)) + { + var value = ReadProbeDecimal(row.Cell(amountColumn)); + if (value == 0m) + continue; + + total += value; + rowsWithAmount++; + + if (currencyColumn > 0) + { + var currency = row.Cell(currencyColumn).GetString().Trim(); + if (!string.IsNullOrWhiteSpace(currency)) + currencies.Add(currency); + } + + if (invoiceDateColumn > 0 && TryReadProbeDate(row.Cell(invoiceDateColumn), out var invoiceDate) && invoiceDate.Year == 2025) + { + totalIn2025 += value; + rowsIn2025++; + } + } + + return new GermanyExcelProbe + { + Path = path, + RowsWithAmount = rowsWithAmount, + SalesPriceValue = total, + RowsIn2025 = rowsIn2025, + SalesPriceValueIn2025 = totalIn2025, + Currencies = string.Join(", ", currencies.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) + }; +} + +static decimal ReadProbeDecimal(IXLCell cell) +{ + if (cell.TryGetValue(out var decimalValue)) + return decimalValue; + + var text = cell.GetString().Trim(); + if (string.IsNullOrWhiteSpace(text)) + return 0m; + + text = text + .Replace("'", string.Empty) + .Replace("’", string.Empty) + .Replace(" ", string.Empty) + .Replace(",", "."); + + return decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : 0m; +} + +static bool TryReadProbeDate(IXLCell cell, out DateTime value) +{ + if (cell.TryGetValue(out value)) + return true; + + return DateTime.TryParse(cell.GetString(), CultureInfo.GetCultureInfo("de-CH"), DateTimeStyles.None, out value) || + DateTime.TryParse(cell.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.None, out value); +} + +static SpainSalesCsvProbe? LoadSpainSalesCsvProbe(string? path) +{ + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + return null; + + using var parser = new TextFieldParser(path) + { + TextFieldType = FieldType.Delimited, + HasFieldsEnclosedInQuotes = true, + TrimWhiteSpace = false + }; + parser.SetDelimiters(";"); + + var header = parser.ReadFields(); + if (header is null) + return null; + + var headerMap = header + .Select((name, index) => new { Name = name.Trim(), Index = index }) + .ToDictionary(x => x.Name, x => x.Index, StringComparer.OrdinalIgnoreCase); + + if (!headerMap.TryGetValue("SalesPriceValue", out var salesIndex)) + return null; + + headerMap.TryGetValue("DocumentType", out var documentTypeIndex); + headerMap.TryGetValue("InvoiceSeries", out var invoiceSeriesIndex); + + var rows = 0; + var total = 0m; + var byDocumentType = new Dictionary(StringComparer.OrdinalIgnoreCase); + var bySeries = new Dictionary(StringComparer.OrdinalIgnoreCase); + + while (!parser.EndOfData) + { + var fields = parser.ReadFields(); + if (fields is null || fields.All(string.IsNullOrWhiteSpace)) + continue; + + var sales = salesIndex < fields.Length ? ParseProbeDecimal(fields[salesIndex]) : 0m; + var documentType = documentTypeIndex < fields.Length && !string.IsNullOrWhiteSpace(fields[documentTypeIndex]) + ? fields[documentTypeIndex] + : "-"; + var series = invoiceSeriesIndex < fields.Length && !string.IsNullOrWhiteSpace(fields[invoiceSeriesIndex]) + ? fields[invoiceSeriesIndex] + : "-"; + + rows++; + total += sales; + AddGroupValue(byDocumentType, documentType, sales); + AddGroupValue(bySeries, series, sales); + } + + const decimal reference = 3102333.61m; + return new SpainSalesCsvProbe + { + Path = path, + Rows = rows, + SalesPriceValue = total, + ReferenceValue = reference, + Difference = total - reference, + ByDocumentType = byDocumentType + .OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase) + .Select(x => new SpainSalesCsvGroup(x.Key, x.Value.Rows, x.Value.Sales)) + .ToList(), + BySeries = bySeries + .OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase) + .Select(x => new SpainSalesCsvGroup(x.Key, x.Value.Rows, x.Value.Sales)) + .ToList() + }; +} + +static void AddGroupValue(Dictionary groups, string key, decimal sales) +{ + groups.TryGetValue(key, out var current); + groups[key] = (current.Rows + 1, current.Sales + sales); +} + +static decimal ParseProbeDecimal(string text) +{ + if (string.IsNullOrWhiteSpace(text)) + return 0m; + + return decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var value) + ? value + : 0m; +} + static string BuildPage( IReadOnlyList rows, string databasePath, - IReadOnlyDictionary excelReferences) + IReadOnlyDictionary excelReferences, + SpainSalesCsvProbe? spainCsv, + GermanyExcelProbe? germanySample) { var generatedAt = DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.GetCultureInfo("de-CH")); var okCount = rows.Count(r => r.Status == "OK"); var checkCount = rows.Count(r => r.Status == "Pruefen"); var missingCount = rows.Count(r => r.Status == "Keine Daten"); var excelCount = excelReferences.Count; + var executiveBriefing = BuildExecutiveBriefing(rows, excelReferences, spainCsv, germanySample); + var detailRows = BuildDetailRows(rows, excelReferences, spainCsv); + var spainCsvSection = BuildSpainCsvSection(spainCsv); + var germanySampleSection = BuildGermanySampleSection(germanySample, excelReferences); return $$""" @@ -156,6 +394,21 @@ static string BuildPage( gap: 12px; line-height: 1.5; } + nav { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; + } + nav a { + color: #1f4f7a; + text-decoration: none; + border: 1px solid var(--line); + border-radius: 6px; + padding: 6px 10px; + background: #f8fafc; + font-weight: 600; + } main { padding: 18px 24px 28px; } .summary { display: grid; @@ -228,6 +481,46 @@ static string BuildPage( position: static; } .small { color: var(--muted); font-size: 12px; } + .briefing { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 6px; + padding: 12px; + margin-bottom: 14px; + } + .briefing h2 { + margin: 0 0 6px; + font-size: 18px; + letter-spacing: 0; + } + .briefing-note { + color: var(--muted); + margin: 0 0 10px; + line-height: 1.45; + } + .ampel { + display: inline-flex; + align-items: center; + gap: 7px; + white-space: nowrap; + font-weight: 650; + } + .ampel::before { + content: ""; + width: 12px; + height: 12px; + border-radius: 999px; + display: inline-block; + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.06); + } + .ampel-ok::before { background: #168a48; } + .ampel-check::before { background: #e6a100; } + .ampel-missing::before { background: #9aa4b2; } + .wrap { + min-width: 240px; + max-width: 420px; + line-height: 1.35; + } @media (max-width: 900px) { main, header { padding-left: 12px; padding-right: 12px; } .summary { grid-template-columns: repeat(2, minmax(120px, 1fr)); } @@ -239,20 +532,27 @@ static string BuildPage(

Finance Probe - Net Sales Actuals 2025

- Vergleich gegen geprüfte Referenzwerte aus check.xlsx / Power BI Stand 29.04.2026 + Vergleich gegen gepruefte Sollwerte aus check.xlsx Stand 29.04.2026 DB: {{Html(databasePath)}} Excel-Referenzen gelesen: {{excelCount}} Aktualisiert: {{Html(generatedAt)}}
+
+ {{executiveBriefing}}
{{rows.Count}}Standorte
{{okCount}}OK
{{checkCount}}Pruefen
{{missingCount}}Keine Daten
-
+
@@ -265,26 +565,304 @@ static string BuildPage( - + - + - {{string.Join(Environment.NewLine, rows.Select(row => BuildRow(row, excelReferences)))}} + {{detailRows}}
Referenz Excel LC Excel CHFExcel Power BIExcel Sollwert Excel Status DifferenzOhne IC Diff.Ohne 2nd-party Diff. Waehrung Zeilen Varianten
+ {{germanySampleSection}} + {{spainCsvSection}}
"""; } +static string BuildDetailRows( + IReadOnlyList rows, + IReadOnlyDictionary excelReferences, + SpainSalesCsvProbe? spainCsv) +{ + var detailRows = rows + .Where(row => spainCsv is null || !row.Key.Equals("ES", StringComparison.OrdinalIgnoreCase)) + .Select(row => (Label: row.Label, Html: BuildRow(row, excelReferences))) + .ToList(); + + if (spainCsv is not null) + { + excelReferences.TryGetValue("Trafag ES", out var excelReference); + detailRows.Add(("Trafag ES", BuildSpainDetailRow(spainCsv, excelReference))); + } + + return string.Join( + Environment.NewLine, + detailRows + .OrderBy(row => row.Label, StringComparer.OrdinalIgnoreCase) + .Select(row => row.Html)); +} + +static string BuildExecutiveBriefing( + IReadOnlyList rows, + IReadOnlyDictionary excelReferences, + SpainSalesCsvProbe? spainCsv, + GermanyExcelProbe? germanySample) +{ + var briefingRows = rows + .Where(row => spainCsv is null || !row.Key.Equals("ES", StringComparison.OrdinalIgnoreCase)) + .Select(row => (Label: row.Label, Html: BuildExecutiveRow(row, germanySample))) + .ToList(); + + if (spainCsv is not null) + briefingRows.Add(("Trafag ES", BuildSpainExecutiveRow(spainCsv))); + + var existingLabels = briefingRows + .Select(row => row.Label) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var reference in excelReferences.Values) + { + if (existingLabels.Contains(reference.Label)) + continue; + + briefingRows.Add((reference.Label, BuildMissingExecutiveRow(reference))); + } + + var tableRows = string.Join( + Environment.NewLine, + briefingRows + .OrderBy(row => row.Label, StringComparer.OrdinalIgnoreCase) + .Select(row => row.Html)); + + return $$""" +
+

Meeting Ampel 2025

+

Gruen = Zahl passt rechnerisch. Gelb = Differenz oder fachliche Abgrenzung offen. Grau = keine belastbaren Importdaten. Fachliche Regel: Net Sales Actuals werden in Hauswaehrung aus dem Nettofakturawert abgegrenzt; CHF-Ausweis nutzt Budgetkurse 2025 und wird pro Belegposition gerechnet, sobald die Positionswerte in Hauswaehrung verfuegbar sind.

+
+ + + + + + + + + + + + + + {{tableRows}} +
AmpelLandIstSollDifferenzPassender WertWaehrung / CHFWarum / offen
+
+
+"""; +} + +static string BuildMissingExecutiveRow(CheckedExcelReference reference) +{ + var referenceValue = reference.PowerBiValue ?? reference.LocalCurrencyValue; + var source = reference.PowerBiValue.HasValue ? "Sollwert" : "LC"; + + return $$""" + + Grau + {{Html(reference.Label)}}
check.xlsx
+ - + {{Amount(referenceValue)}} + - + Kein Ist-Import (check.xlsx {{Html(source)}}) + Waehrung aus Quelle noch nicht belegbar. CHF nur wenn check.xlsx-Spalte CHF verwendet wird. + In check.xlsx vorhanden, aber im aktuellen Import/aktiven Standort nicht belastbar. Export oder Standortaktivierung pruefen. + +"""; +} + +static string BuildExecutiveRow(NetSalesReferenceRow row, GermanyExcelProbe? germanySample) +{ + var ampelClass = row.Status switch + { + "OK" => "ampel-ok", + "Pruefen" => "ampel-check", + _ => "ampel-missing" + }; + var ampelText = row.Status switch + { + "OK" => "Gruen", + "Pruefen" => "Gelb", + _ => "Grau" + }; + var matchingValue = string.IsNullOrWhiteSpace(row.ValueField) + ? "Noch kein Wert gewaehlt" + : $"{row.ValueField} ({row.ReferenceSource})"; + + return $$""" + + {{ampelText}} + {{Html(row.Label)}}
{{Html(row.Key)}}
+ {{Amount(row.ActualValue)}} + {{Amount(row.ReferenceValue)}} + {{Amount(row.Difference)}} + {{Html(matchingValue)}} + {{Html(BuildCurrencyNote(row))}} + {{Html(BuildExecutiveReason(row, germanySample))}} + +"""; +} + +static string BuildSpainExecutiveRow(SpainSalesCsvProbe spainCsv) +{ + var ampelClass = Math.Abs(spainCsv.Difference) <= 1m ? "ampel-ok" : "ampel-check"; + var ampelText = Math.Abs(spainCsv.Difference) <= 1m ? "Gruen" : "Gelb"; + + return $$""" + + {{ampelText}} + Trafag ES
ES / Sage Spain v2
+ {{Amount(spainCsv.SalesPriceValue)}} + {{Amount(spainCsv.ReferenceValue)}} + {{Amount(spainCsv.Difference)}} + SalesPriceValue aus Spain_Sales_2025.csv + EUR Hauswaehrung. CHF ueber Budgetkurs 2025. + Export technisch lesbar, aber noch Differenz. Klaeren: Datumsabgrenzung, Serien REG/LAT/PRO/REC und Gutschriften. + +"""; +} + +static string BuildCurrencyNote(NetSalesReferenceRow row) +{ + var actualCurrency = row.ActualCurrency.Trim(); + var currencies = row.Currencies.Trim(); + + if (string.IsNullOrWhiteSpace(actualCurrency) && string.IsNullOrWhiteSpace(currencies)) + return "Waehrung noch nicht belegt."; + + if (actualCurrency.Contains("CHF", StringComparison.OrdinalIgnoreCase) && + !actualCurrency.Contains(',', StringComparison.Ordinal)) + { + return "CHF direkt aus Quelle."; + } + + if (actualCurrency.Contains(',', StringComparison.Ordinal) || currencies.Contains(',', StringComparison.Ordinal)) + return $"Gemischte Quellwaehrungen ({PreferNonBlank(actualCurrency, currencies)}). Fachlich ist Hauswaehrung fuehrend; Mapping/Quelle pruefen."; + + return $"{PreferNonBlank(actualCurrency, currencies)} Hauswaehrung. CHF ueber Budgetkurs 2025."; +} + +static string BuildExecutiveReason(NetSalesReferenceRow row, GermanyExcelProbe? germanySample) +{ + if (row.Key.Equals("DE", StringComparison.OrdinalIgnoreCase) && germanySample is not null) + { + return $"DE-Beispielfile gefunden und lesbar: {germanySample.RowsWithAmount} Betragszeilen, Summe {Amount(germanySample.SalesPriceValue)} {germanySample.Currencies}. Das ist ein Sample, kein finaler Jahresexport."; + } + + if (row.Status == "OK") + return "Passt rechnerisch gegen check.xlsx. Hauswaehrung ist fachlich fuehrend."; + + if (row.Status == "Keine Daten") + return "Keine belastbaren Daten im Import. Standort/Export/Mapping pruefen."; + + if (row.DifferenceExcludingIntercompany.HasValue && + Math.Abs(row.DifferenceExcludingIntercompany.Value) <= 1m) + { + return "Differenz ist nach 2nd-party/Intercompany-Abzug rechnerisch erklaerbar. IC-Kunden sollen spaeter als eigenes Feld gepflegt werden."; + } + + if (row.Candidates.Count > 1) + return "Mehrere technische Summen sichtbar. Gewaehlter Wert folgt der Fachregel: Hauswaehrung / Nettofakturawert."; + + return "Differenz offen. Quelle, Periodenabgrenzung, Gutschriften und 2nd-party/3rd-party-Abgrenzung pruefen."; +} + +static string PreferNonBlank(string first, string second) + => !string.IsNullOrWhiteSpace(first) ? first : second; + +static string BuildGermanySampleSection( + GermanyExcelProbe? germanySample, + IReadOnlyDictionary excelReferences) +{ + if (germanySample is null) + { + return """ +
+ Germany Excel + Keine DE_Beispiel_Export_Daten.xlsx im Repo gefunden. +
+"""; + } + + excelReferences.TryGetValue("Trafag DE", out var reference); + var referenceValue = reference?.PowerBiValue ?? reference?.LocalCurrencyValue; + var difference = referenceValue.HasValue ? germanySample.SalesPriceValue - referenceValue.Value : (decimal?)null; + + return $$""" +
+

Germany Excel sample check

+
+
{{germanySample.RowsWithAmount}}Betragszeilen
+
{{Amount(germanySample.SalesPriceValue)}}NettoPreisGesamtX {{Html(germanySample.Currencies)}}
+
{{Amount(referenceValue)}}check.xlsx DE Referenz
+
{{Amount(difference)}}Differenz nur Sample
+
+
Datei: {{Html(germanySample.Path)}}
+
Interpretation: Mapping funktioniert technisch. Diese Datei heisst Beispielfile und enthaelt nur {{germanySample.RowsWithAmount}} Betragszeilen; sie darf deshalb nicht als finale Deutschland-Jahreszahl verwendet werden.
+
+"""; +} + +static string BuildSpainCsvSection(SpainSalesCsvProbe? spainCsv) +{ + if (spainCsv is null) + { + return """ +
+ Spain CSV + Keine Spain_Sales_2025.csv im Repo gefunden. +
+"""; + } + + var documentRows = string.Join(Environment.NewLine, spainCsv.ByDocumentType.Select(group => $$""" + {{Html(group.Label)}}{{group.Rows}}{{Amount(group.Sales)}} +""")); + var seriesRows = string.Join(Environment.NewLine, spainCsv.BySeries.Select(group => $$""" + {{Html(group.Label)}}{{group.Rows}}{{Amount(group.Sales)}} +""")); + + return $$""" +
+

Spain CSV direct check

+
+
{{spainCsv.Rows}}CSV-Zeilen
+
{{Amount(spainCsv.SalesPriceValue)}}SalesPriceValue EUR
+
{{Amount(spainCsv.ReferenceValue)}}check.xlsx ES
+
{{Amount(spainCsv.Difference)}}Differenz
+
+
Datei: {{Html(spainCsv.Path)}}
+
+ + + {{documentRows}} +
DocumentTypeZeilenSales
+
+
+ + + {{seriesRows}} +
InvoiceSeriesZeilenSales
+
+
+"""; +} + static string BuildRow(NetSalesReferenceRow row, IReadOnlyDictionary excelReferences) { var statusClass = row.Status.Replace(" ", string.Empty); @@ -312,6 +890,32 @@ static string BuildRow(NetSalesReferenceRow row, IReadOnlyDictionary + {{status}} + Trafag ES
ES / Sage Spain v2 CSV
+ SalesPriceValue CSV + EUR + {{Amount(spainCsv.SalesPriceValue)}} + LC + {{Amount(spainCsv.ReferenceValue)}} + {{Amount(excelReference?.LocalCurrencyValue)}} + {{Amount(excelReference?.ChfValue)}} + {{Amount(excelReference?.PowerBiValue)}} + {{Html(excelReference?.Status)}} + {{Amount(spainCsv.Difference)}} + - + EUR + {{spainCsv.Rows}} + CSV-Details anzeigen + +"""; +} + static string BuildCandidateDetails(NetSalesReferenceRow row) { if (row.Candidates.Count == 0) @@ -338,8 +942,8 @@ static string BuildCandidateDetails(NetSalesReferenceRow row) Waehrung Wert Diff. - IC - Diff. ohne IC + 2nd-party/IC + Diff. ohne 2nd-party {{candidateRows}} @@ -362,3 +966,26 @@ sealed class CheckedExcelReference public decimal? PowerBiValue { get; set; } public string Status { get; set; } = string.Empty; } + +sealed class SpainSalesCsvProbe +{ + public string Path { get; set; } = string.Empty; + public int Rows { get; set; } + public decimal SalesPriceValue { get; set; } + public decimal ReferenceValue { get; set; } + public decimal Difference { get; set; } + public List ByDocumentType { get; set; } = []; + public List BySeries { get; set; } = []; +} + +sealed record SpainSalesCsvGroup(string Label, int Rows, decimal Sales); + +sealed class GermanyExcelProbe +{ + public string Path { get; set; } = string.Empty; + public int RowsWithAmount { get; set; } + public decimal SalesPriceValue { get; set; } + public int RowsIn2025 { get; set; } + public decimal SalesPriceValueIn2025 { get; set; } + public string Currencies { get; set; } = string.Empty; +} diff --git a/TrafagSalesExporter/Tools/FinanceProbe/Properties/launchSettings.json b/TrafagSalesExporter/Tools/FinanceProbe/Properties/launchSettings.json new file mode 100644 index 0000000..86457bd --- /dev/null +++ b/TrafagSalesExporter/Tools/FinanceProbe/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "FinanceProbe": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:59120;http://localhost:59121" + } + } +} \ No newline at end of file diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs index a457079..7af5eab 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs @@ -297,6 +297,48 @@ public class ManualExcelImportServiceTests } } + [Fact] + public async Task ReadSalesRecordsAsync_Reads_Sage_Spain_Csv_Format() + { + var site = new Site + { + TSC = "TRES", + Land = "Spanien" + }; + var filePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.csv"); + var csv = string.Join(Environment.NewLine, + "\"TSC\";\"Land\";\"InvoiceNumber\";\"PositionOnInvoice\";\"Material\";\"Name\";\"ProductGroup\";\"Quantity\";\"CustomerNumber\";\"CustomerName\";\"CustomerCountry\";\"StandardCost\";\"StandardCostCurrency\";\"PurchaseOrderNumber\";\"SalesPriceValue\";\"SalesCurrency\";\"DocumentCurrency\";\"CompanyCurrency\";\"Incoterms2020\";\"SalesResponsibleEmployee\";\"InvoiceDate\";\"DocumentType\"", + "\"TRES\";\"Spanien\";\"20241332\";\"20\";\"52871\";\"ECL1.0AP\";\"TRANS\";\"1.000000\";\"302208\";\"INTRONIK AUTOMATIZACION E INST. SL\";\"ESPANA\";\"160.760000\";\"EUR\";\"PC240330\";\"265.000000\";\"EUR\";\"EUR\";\"EUR\";\"EXW\";\"1\";\"2025-01-02 00:00:00\";\"Invoice\""); + await File.WriteAllTextAsync(filePath, csv); + + try + { + var service = new ManualExcelImportService(); + + var rows = await service.ReadSalesRecordsAsync(filePath, site); + + var row = Assert.Single(rows); + Assert.Equal("TRES", row.Tsc); + Assert.Equal("Spanien", row.Land); + Assert.Equal("20241332", row.InvoiceNumber); + Assert.Equal(20, row.PositionOnInvoice); + Assert.Equal("52871", row.Material); + Assert.Equal("TRANS", row.ProductGroup); + Assert.Equal(1m, row.Quantity); + Assert.Equal(160.760000m, row.StandardCost); + Assert.Equal(265.000000m, row.SalesPriceValue); + Assert.Equal("EUR", row.SalesCurrency); + Assert.Equal("EUR", row.DocumentCurrency); + Assert.Equal("EUR", row.CompanyCurrency); + Assert.Equal(new DateTime(2025, 1, 2), row.InvoiceDate); + Assert.Equal("Invoice", row.DocumentType); + } + finally + { + File.Delete(filePath); + } + } + private static string CreateWorkbook(Action fillWorkbook) { var filePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.xlsx"); diff --git a/TrafagSalesExporter/check.xlsx b/TrafagSalesExporter/check.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..382b039ef894fce8f1afa7783a17a11f2c9450d7 GIT binary patch literal 17860 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 literal 0 HcmV?d00001 diff --git a/TrafagSalesExporter/lastchange.md b/TrafagSalesExporter/lastchange.md index 90c6f87..0fbc245 100644 --- a/TrafagSalesExporter/lastchange.md +++ b/TrafagSalesExporter/lastchange.md @@ -1,5 +1,40 @@ # Last Change 2026-05-04 +## Finance-Abgrenzung: Antworten Andreas 2026-05-07 + +Fachliche Vorgabe nach Rueckmeldung: + +- Net Sales Actuals werden in Hauswaehrung gerechnet. +- Massgebend ist der Nettofakturawert. +- Umrechnung nach CHF erfolgt mit Budgetkursen, nicht mit Tageskursen. +- Umrechnung/Summierung soll pro Artikel bzw. Belegposition erfolgen. +- Indien wird in INR betrachtet. +- Italien wird in Hauswaehrung betrachtet; Intercompany-/2nd-party-Abgrenzung wird separat angeschaut. +- UK wird in GBP betrachtet. +- Gutschriften haben eigene Rechnungsnummern/Rechnungspositionen und sollen ueber Artikelnummern/Positionen behandelt werden. +- Intercompany soll im zweiten Schritt als 2nd-party/3rd-party-Klassifikation pflegbar werden. +- Genannte 2nd-party/Intercompany-Indikatoren: Trafag, Magnetic Sense/Magnets Sense, Gesellschaft fuer Sensorik; Nummern/Uebersetzungen koennen je Land abweichen. + +Budgetkurse 2025 fuer CHF-Ausweis: + +```text +USD/CHF = 0.85 +EUR/CHF = 0.95 +GBP/CHF = 1.13 +CHF/INR = 90.91 +CHF/CZK = 25.64 +PLN/CHF = 0.22 +CHF/JPY = 156.25 +``` + +Umsetzung in der FinanceProbe: + +- Auswahl der Ist-Variante bevorzugt nun `Nettofakturawert Hauswaehrung` (`DocTotal - VatSum`). +- `Sales Price/Value` bleibt als Vergleichsvariante sichtbar. +- Zusaetzlicher Kandidat `Nettofakturawert Hauswaehrung -> CHF Budget 2025`. +- Referenz in der Oberflaeche wird als `check.xlsx Sollwert` bezeichnet, nicht mehr als fuehrende Power-BI-Referenz. +- Intercompany-Anzeige wurde fachlich als `2nd-party/IC` beschriftet; dauerhafte Pflege als eigenes Auswahlfeld ist noch offen. + ## Finance Probe / Sales-Abgrenzung Ziel der heutigen Arbeit: @@ -635,3 +670,126 @@ Hinweis: - nicht statischen Code bauen - neues Mapping pro Standort pflegen 6. Klaeren, ob DE fachlich `NettoPreisGesamtX` in EUR als Ist-Wert verwenden soll oder ob CHF-Umrechnung noetig ist. + +--- + +## Nachtrag 2026-05-05: FinanceProbe Ampel, Spanien v2 und Deutschland-Beispielfile + +### FinanceProbe Management-Ansicht + +Das Testprogramm `Tools/FinanceProbe` wurde fuer das Finance-Meeting erweitert. + +URL lokal: + +```text +http://localhost:55417/finance +``` + +Neue Ansicht: + +- `Meeting Ampel 2025` +- Ampel pro Land: + - Gruen: Zahl passt rechnerisch gegen Referenz + - Gelb: Differenz oder fachliche Abgrenzung offen + - Grau: keine belastbaren Importdaten +- Anzeige pro Land: + - Ist + - Soll / Referenz + - Differenz + - passender technischer Wert + - Waehrung / CHF-Hinweis + - kurze fachliche Begruendung + +Wichtig zur Waehrung: + +- Wenn Quelle `CHF` liefert, kann CHF direkt gezeigt werden. +- Wenn Quelle `EUR`, `USD`, `GBP`, `INR` usw. liefert, ist es Mandanten-/Originalwaehrung. +- CHF-Ausweis braucht dann eine separate FX-Regel bzw. offiziellen Umrechnungskurs. + +### Spanien v2 im Testprogramm + +Spanien wird im FinanceProbe nicht mehr nur als normaler Zentralimport betrachtet. + +Direkter CSV-Check: + +```text +sagespain/v2/Spain_Sales_2025.csv +``` + +Gelesene Werte: + +- Zeilen: `4'341` +- Ist 2025 / `SalesPriceValue`: `3'082'320.18` +- Waehrung: `EUR` +- Soll aus `check.xlsx`: `3'102'333.61` +- Differenz: `-20'013.43` + +Status: + +- Ampel: Gelb / Pruefen +- Grund: Export technisch lesbar, aber Differenz zu `check.xlsx` offen. + +Offen fuer Spanien: + +- korrekte Datumsabgrenzung (`FechaFactura` vs. Alternativen) +- Serien `REG`, `LAT`, `PRO`, `REC` +- Behandlung von Gutschriften / `REC` +- offizielle Sage-Auswertung mit identischem Filter zur Sollzahl + +### Deutschland-Beispielfile + +Neues File im Projektordner: + +```text +DE_Beispiel_Export_Daten.xlsx +``` + +Hinweis: + +- Der Benutzer hatte zuerst `.xls` genannt, vorhanden ist `.xlsx`. +- Das File ist als Beispielfile zu behandeln, nicht als finale Jahresdatei. + +Technischer Check: + +- relevante Spalte: `NettoPreisGesamtX` +- Mapping-Ziel: `SalesPriceValue` +- Betragszeilen: `2` +- Summe `NettoPreisGesamtX`: `8'290.70` +- Waehrung: `EUR` + +Einbau im FinanceProbe: + +- eigener Abschnitt `Germany Excel sample check` +- zeigt Datei, Zeilenzahl, Summe und Referenz aus `check.xlsx` +- markiert explizit, dass die Differenz nur Sample-Charakter hat +- in der Management-Ampel wird Deutschland weiter nicht als OK gewertet, solange kein finaler DE-Jahresexport/import vorliegt + +Fachliche Interpretation fuer Deutschland: + +- Das Mapping funktioniert technisch. +- `NettoPreisGesamtX` kann als Kandidat fuer `SalesPriceValue` gelesen werden. +- Das Beispielfile darf nicht gegen die Jahresreferenz `3'635'922.91` als finale Ist-Zahl verwendet werden. +- Fuer das Meeting ist die Aussage: + - Deutschland-Format ist technisch verstanden. + - Finale DE-Zahl fehlt noch. + - Benoetigt wird ein vollstaendiger DE-Jahresfile 2025 oder ein bestaetigter Importlauf. + +### Verifikation 2026-05-05 + +Ausgefuehrt: + +```text +dotnet build .\Tools\FinanceProbe\FinanceProbe.csproj --verbosity minimal --no-restore +dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal --no-restore +``` + +Ergebnis: + +- FinanceProbe Build erfolgreich +- Tests erfolgreich +- `50/50` Tests gruen +- Web UI liefert `HTTP 200` +- FinanceProbe enthaelt: + - `Meeting Ampel 2025` + - `Spain CSV direct check` + - `Germany Excel sample check`