Split ManagementCockpitService god class into focused analyzers
Extract the three independent responsibilities of the 1120-line ManagementCockpitService into dedicated classes: ExcelCockpitAnalyzer (file-based cockpit), CentralCockpitAnalyzer (central database cockpit) and FinanceSummaryAnalyzer (finance summary), with shared currency conversion and value-field logic in CockpitValueAggregator. ManagementCockpitService becomes a thin facade that preserves the IManagementCockpitService contract and both constructors, so no callers, DI registrations or tests need to change. Pure code move, no behaviour change. https://claude.ai/code/session_01Q8k7LD7JG8oMReySL3Ckhc
This commit is contained in:
@@ -0,0 +1,448 @@
|
||||
using ClosedXML.Excel;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrafagSalesExporter.Data;
|
||||
using TrafagSalesExporter.Models;
|
||||
using static TrafagSalesExporter.Services.CockpitValueAggregator;
|
||||
|
||||
namespace TrafagSalesExporter.Services;
|
||||
|
||||
internal sealed class ExcelCockpitAnalyzer
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||
private readonly CockpitValueAggregator _aggregator;
|
||||
|
||||
public ExcelCockpitAnalyzer(IDbContextFactory<AppDbContext> dbFactory, CockpitValueAggregator aggregator)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_aggregator = aggregator;
|
||||
}
|
||||
|
||||
public async Task<List<ManagementCockpitFileOption>> GetAvailableFilesAsync()
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
|
||||
var exportLogs = await db.ExportLogs
|
||||
.Where(x => x.Status == "OK" && !string.IsNullOrWhiteSpace(x.FilePath))
|
||||
.OrderByDescending(x => x.Timestamp)
|
||||
.Take(200)
|
||||
.ToListAsync();
|
||||
|
||||
var files = new Dictionary<string, ManagementCockpitFileOption>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var log in exportLogs)
|
||||
{
|
||||
if (!File.Exists(log.FilePath))
|
||||
continue;
|
||||
|
||||
files[log.FilePath] = new ManagementCockpitFileOption
|
||||
{
|
||||
Path = log.FilePath,
|
||||
DisplayName = $"{log.Land} | {log.TSC} | {Path.GetFileName(log.FilePath)}",
|
||||
LastModified = File.GetLastWriteTime(log.FilePath)
|
||||
};
|
||||
}
|
||||
|
||||
foreach (var directory in GetCandidateDirectories(settings))
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
continue;
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(directory, "*.xlsx", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
if (files.ContainsKey(file))
|
||||
continue;
|
||||
|
||||
var fileName = Path.GetFileName(file);
|
||||
files[file] = new ManagementCockpitFileOption
|
||||
{
|
||||
Path = file,
|
||||
DisplayName = fileName,
|
||||
LastModified = File.GetLastWriteTime(file)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return files.Values
|
||||
.OrderByDescending(x => x.LastModified)
|
||||
.ThenBy(x => x.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public Task<ManagementCockpitResult> AnalyzeAsync(string filePath, ManagementCockpitAnalysisOptions? options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||
throw new InvalidOperationException("Die ausgewählte Excel-Datei wurde nicht gefunden.");
|
||||
|
||||
var aggregation = _aggregator.ResolveAggregation(options);
|
||||
using var workbook = new XLWorkbook(filePath);
|
||||
var worksheet = workbook.Worksheets.First();
|
||||
var usedRange = worksheet.RangeUsed() ?? throw new InvalidOperationException("Die Excel-Datei enthält keine Daten.");
|
||||
|
||||
var headerRow = usedRange.FirstRow();
|
||||
var headers = headerRow.Cells()
|
||||
.Select((cell, index) => new { Index = index + 1, Header = NormalizeHeader(cell.GetString()) })
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.Header))
|
||||
.ToDictionary(x => x.Header, x => x.Index, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var rows = new List<CockpitRow>();
|
||||
foreach (var row in usedRange.RowsUsed().Skip(1))
|
||||
{
|
||||
if (row.CellsUsed().All(c => string.IsNullOrWhiteSpace(c.GetString())))
|
||||
continue;
|
||||
|
||||
rows.Add(ReadRow(row, headers));
|
||||
}
|
||||
|
||||
if (rows.Count == 0)
|
||||
throw new InvalidOperationException("Die Excel-Datei enthält keine auswertbaren Datenzeilen.");
|
||||
|
||||
ApplyAggregation(rows, aggregation);
|
||||
|
||||
var result = new ManagementCockpitResult
|
||||
{
|
||||
FilePath = filePath,
|
||||
Summary = BuildSummary(rows, aggregation),
|
||||
Findings = BuildFindings(rows, aggregation),
|
||||
TopCustomers = BuildTopItems(rows, x => x.CustomerName, x => x.AggregatedValue),
|
||||
TopProductGroups = BuildTopItems(rows, x => x.ProductGroup, x => x.AggregatedValue),
|
||||
TopSalesEmployees = BuildTopItems(rows, x => x.SalesResponsibleEmployee, x => x.AggregatedValue),
|
||||
DataQualityCounts = BuildDataQualityCounts(rows)
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetCandidateDirectories(ExportSettings settings)
|
||||
{
|
||||
yield return Path.Combine(AppContext.BaseDirectory, "output");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(settings.LocalSiteExportFolder))
|
||||
yield return settings.LocalSiteExportFolder.Trim();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(settings.LocalConsolidatedExportFolder))
|
||||
yield return settings.LocalConsolidatedExportFolder.Trim();
|
||||
}
|
||||
|
||||
private void ApplyAggregation(List<CockpitRow> rows, AggregationSelection aggregation)
|
||||
{
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var value = ResolveValue(row, aggregation.ValueField);
|
||||
var currency = ResolveCurrency(row, aggregation.ValueField);
|
||||
var converted = _aggregator.ConvertValue(value, currency, aggregation.ValueField, aggregation, row.InvoiceDate ?? row.OrderDate ?? row.ExtractionDate);
|
||||
|
||||
row.AggregatedValue = converted.Value;
|
||||
row.AggregatedCurrency = converted.DisplayCurrency;
|
||||
row.MissingExchangeRate = converted.MissingExchangeRate;
|
||||
}
|
||||
}
|
||||
|
||||
private static decimal ResolveValue(CockpitRow row, ValueFieldDefinition field)
|
||||
=> field.Key switch
|
||||
{
|
||||
ManagementCockpitValueFieldKeys.Quantity => row.Quantity,
|
||||
ManagementCockpitValueFieldKeys.StandardCost => row.StandardCost,
|
||||
ManagementCockpitValueFieldKeys.StandardCostTotal => row.EstimatedCostTotal,
|
||||
_ => row.SalesValueTotal
|
||||
};
|
||||
|
||||
private static string ResolveCurrency(CockpitRow row, ValueFieldDefinition field)
|
||||
=> field.CurrencySource switch
|
||||
{
|
||||
ValueCurrencySource.StandardCost => row.StandardCostCurrency,
|
||||
ValueCurrencySource.Sales => row.SalesCurrency,
|
||||
_ => "-"
|
||||
};
|
||||
|
||||
private static CockpitRow ReadRow(IXLRangeRow row, IReadOnlyDictionary<string, int> headers)
|
||||
{
|
||||
var quantity = GetDecimal(row, headers, "quantity");
|
||||
var standardCost = GetDecimal(row, headers, "standardcost");
|
||||
var salesValue = GetDecimal(row, headers, "salespricevalue");
|
||||
var estimatedCostTotal = quantity != 0m ? quantity * standardCost : standardCost;
|
||||
|
||||
return new CockpitRow
|
||||
{
|
||||
ExtractionDate = GetDate(row, headers, "extractiondate"),
|
||||
Tsc = GetText(row, headers, "tsc"),
|
||||
InvoiceNumber = GetText(row, headers, "invoicenumber"),
|
||||
PositionOnInvoice = GetText(row, headers, "positiononinvoice"),
|
||||
Material = GetText(row, headers, "material"),
|
||||
Name = GetText(row, headers, "name"),
|
||||
ProductGroup = GetText(row, headers, "productgroup"),
|
||||
Quantity = quantity,
|
||||
SupplierNumber = GetText(row, headers, "suppliernumber"),
|
||||
SupplierName = GetText(row, headers, "suppliername"),
|
||||
SupplierCountry = GetText(row, headers, "suppliercountry"),
|
||||
CustomerNumber = GetText(row, headers, "customernumber"),
|
||||
CustomerName = GetText(row, headers, "customername"),
|
||||
CustomerCountry = GetText(row, headers, "customercountry"),
|
||||
CustomerIndustry = GetText(row, headers, "customerindustry"),
|
||||
StandardCost = standardCost,
|
||||
StandardCostCurrency = GetText(row, headers, "standardcostcurrency"),
|
||||
SalesValueTotal = salesValue,
|
||||
SalesCurrency = GetText(row, headers, "salescurrency"),
|
||||
Incoterms2020 = GetText(row, headers, "incoterms2020"),
|
||||
SalesResponsibleEmployee = GetText(row, headers, "salesresponsibleemployee"),
|
||||
InvoiceDate = GetDate(row, headers, "invoicedate"),
|
||||
OrderDate = GetDate(row, headers, "orderdate"),
|
||||
Land = GetText(row, headers, "land"),
|
||||
EstimatedCostTotal = estimatedCostTotal,
|
||||
EstimatedMarginTotal = salesValue - estimatedCostTotal
|
||||
};
|
||||
}
|
||||
|
||||
private static ManagementCockpitSummary BuildSummary(List<CockpitRow> rows, AggregationSelection aggregation)
|
||||
{
|
||||
var aggregatedTotal = rows.Sum(x => x.AggregatedValue);
|
||||
var salesTotal = rows.Sum(x => x.SalesValueTotal);
|
||||
var costTotal = rows.Sum(x => x.EstimatedCostTotal);
|
||||
var marginTotal = rows.Sum(x => x.EstimatedMarginTotal);
|
||||
var serviceRows = rows.Where(x =>
|
||||
x.ProductGroup.Contains("service", StringComparison.OrdinalIgnoreCase) ||
|
||||
x.Name.Contains("port", StringComparison.OrdinalIgnoreCase) ||
|
||||
x.Name.Contains("zeugnis", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
return new ManagementCockpitSummary
|
||||
{
|
||||
Land = rows.Select(x => x.Land).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? "-",
|
||||
Tsc = rows.Select(x => x.Tsc).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? "-",
|
||||
ExtractionDate = rows.Select(x => x.ExtractionDate).FirstOrDefault(x => x.HasValue),
|
||||
RowCount = rows.Count,
|
||||
InvoiceCount = rows.Select(x => x.InvoiceNumber).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
|
||||
CustomerCount = rows.Select(x => x.CustomerName).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
|
||||
ValueFieldKey = aggregation.ValueField.Key,
|
||||
ValueFieldLabel = aggregation.ValueField.Label,
|
||||
DisplayCurrency = BuildDisplayCurrencyLabel(rows.Select(x => x.AggregatedCurrency)),
|
||||
MissingExchangeRateCount = rows.Count(x => x.MissingExchangeRate),
|
||||
AggregatedValueTotal = aggregatedTotal,
|
||||
SalesValueTotal = aggregatedTotal,
|
||||
EstimatedCostTotal = costTotal,
|
||||
EstimatedMarginTotal = marginTotal,
|
||||
EstimatedMarginPercent = salesTotal == 0 ? 0 : marginTotal / salesTotal * 100m,
|
||||
ServiceSharePercent = salesTotal == 0 ? 0 : serviceRows.Sum(x => x.SalesValueTotal) / salesTotal * 100m,
|
||||
MissingOrderDatePercent = rows.Count == 0 ? 0 : rows.Count(x => !x.OrderDate.HasValue) * 100m / rows.Count,
|
||||
MissingSupplierPercent = rows.Count == 0 ? 0 : rows.Count(x => string.IsNullOrWhiteSpace(x.SupplierName) && string.IsNullOrWhiteSpace(x.SupplierNumber)) * 100m / rows.Count
|
||||
};
|
||||
}
|
||||
|
||||
private static List<ManagementCockpitFinding> BuildFindings(List<CockpitRow> rows, AggregationSelection aggregation)
|
||||
{
|
||||
var findings = new List<ManagementCockpitFinding>();
|
||||
var salesTotal = rows.Sum(x => x.AggregatedValue);
|
||||
var topCustomer = rows
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.CustomerName))
|
||||
.GroupBy(x => x.CustomerName, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => new { Customer = g.Key, Sales = g.Sum(x => x.AggregatedValue) })
|
||||
.OrderByDescending(x => x.Sales)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (topCustomer is not null && salesTotal > 0)
|
||||
{
|
||||
var share = topCustomer.Sales / salesTotal * 100m;
|
||||
findings.Add(new ManagementCockpitFinding
|
||||
{
|
||||
Severity = share >= 50 ? "Warning" : "Info",
|
||||
Title = "Kundenkonzentration",
|
||||
Detail = $"{topCustomer.Customer} trägt {share:F1}% des Umsatzes."
|
||||
});
|
||||
}
|
||||
|
||||
var missingExchangeRateRows = rows.Count(x => x.MissingExchangeRate);
|
||||
if (missingExchangeRateRows > 0)
|
||||
{
|
||||
findings.Add(new ManagementCockpitFinding
|
||||
{
|
||||
Severity = "Warning",
|
||||
Title = "Fehlende Wechselkurse",
|
||||
Detail = $"{missingExchangeRateRows} Zeilen konnten nicht in die gewaehlte Anzeige-Waehrung umgerechnet werden."
|
||||
});
|
||||
}
|
||||
|
||||
var zeroValueRows = rows.Where(x => x.SalesValueTotal == 0 || x.StandardCost == 0).ToList();
|
||||
if (zeroValueRows.Count > 0)
|
||||
{
|
||||
findings.Add(new ManagementCockpitFinding
|
||||
{
|
||||
Severity = zeroValueRows.Count >= Math.Max(3, rows.Count / 10) ? "Warning" : "Info",
|
||||
Title = "Nullwerte in Kosten oder Umsatz",
|
||||
Detail = $"{zeroValueRows.Count} Zeilen haben 0 in Umsatz oder Standard Cost und sollten fachlich geprüft werden."
|
||||
});
|
||||
}
|
||||
|
||||
var missingOrderDates = rows.Count(x => !x.OrderDate.HasValue);
|
||||
if (missingOrderDates > 0)
|
||||
{
|
||||
findings.Add(new ManagementCockpitFinding
|
||||
{
|
||||
Severity = missingOrderDates > rows.Count / 2 ? "Warning" : "Info",
|
||||
Title = "Fehlende Durchlaufzeit",
|
||||
Detail = $"{missingOrderDates} von {rows.Count} Zeilen haben kein Order Date. Time-to-Invoice ist nur eingeschränkt beurteilbar."
|
||||
});
|
||||
}
|
||||
|
||||
var orderLeadTimes = rows
|
||||
.Where(x => x.OrderDate.HasValue && x.InvoiceDate.HasValue)
|
||||
.Select(x => (x.InvoiceDate!.Value - x.OrderDate!.Value).TotalDays)
|
||||
.Where(x => x >= 0)
|
||||
.ToList();
|
||||
if (orderLeadTimes.Count > 0)
|
||||
{
|
||||
findings.Add(new ManagementCockpitFinding
|
||||
{
|
||||
Severity = orderLeadTimes.Average() > 120 ? "Warning" : "Info",
|
||||
Title = "Durchschnittliche Fakturierungszeit",
|
||||
Detail = $"Zwischen Order Date und Invoice Date liegen im Schnitt {orderLeadTimes.Average():F0} Tage."
|
||||
});
|
||||
}
|
||||
|
||||
var missingIndustries = rows.Count(x => string.IsNullOrWhiteSpace(x.CustomerIndustry));
|
||||
if (missingIndustries > 0)
|
||||
{
|
||||
findings.Add(new ManagementCockpitFinding
|
||||
{
|
||||
Severity = missingIndustries > rows.Count / 2 ? "Warning" : "Info",
|
||||
Title = "Stammdatenlücke Customer Industry",
|
||||
Detail = $"{missingIndustries} Zeilen haben keine Customer Industry. Marktsegment-Analysen sind dadurch unvollständig."
|
||||
});
|
||||
}
|
||||
|
||||
var missingIncoterms = rows.Count(x => string.IsNullOrWhiteSpace(x.Incoterms2020));
|
||||
if (missingIncoterms > 0)
|
||||
{
|
||||
findings.Add(new ManagementCockpitFinding
|
||||
{
|
||||
Severity = missingIncoterms > rows.Count / 2 ? "Info" : "Info",
|
||||
Title = "Incoterms unvollständig",
|
||||
Detail = $"{missingIncoterms} Zeilen haben keine Incoterms-Angabe."
|
||||
});
|
||||
}
|
||||
|
||||
if (findings.Count == 0)
|
||||
{
|
||||
findings.Add(new ManagementCockpitFinding
|
||||
{
|
||||
Severity = "Info",
|
||||
Title = "Keine auffälligen Datenqualitätsprobleme",
|
||||
Detail = "Die Datei ist für eine erste Standortbeurteilung konsistent genug."
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
private static List<ManagementCockpitTopItem> BuildTopItems(
|
||||
List<CockpitRow> rows,
|
||||
Func<CockpitRow, string> keySelector,
|
||||
Func<CockpitRow, decimal> valueSelector)
|
||||
{
|
||||
var total = rows.Sum(valueSelector);
|
||||
return rows
|
||||
.Select(x => new { Label = keySelector(x), Value = valueSelector(x) })
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.Label))
|
||||
.GroupBy(x => x.Label, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => new ManagementCockpitTopItem
|
||||
{
|
||||
Label = g.Key,
|
||||
Value = g.Sum(x => x.Value),
|
||||
SharePercent = total == 0 ? 0 : g.Sum(x => x.Value) / total * 100m
|
||||
})
|
||||
.OrderByDescending(x => x.Value)
|
||||
.Take(5)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> BuildDataQualityCounts(List<CockpitRow> rows)
|
||||
{
|
||||
return new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Fehlende Supplier"] = rows.Count(x => string.IsNullOrWhiteSpace(x.SupplierName) && string.IsNullOrWhiteSpace(x.SupplierNumber)),
|
||||
["Fehlende Customer Industry"] = rows.Count(x => string.IsNullOrWhiteSpace(x.CustomerIndustry)),
|
||||
["Fehlende Order Date"] = rows.Count(x => !x.OrderDate.HasValue),
|
||||
["Fehlende Invoice Date"] = rows.Count(x => !x.InvoiceDate.HasValue),
|
||||
["Null Umsatz/Kosten"] = rows.Count(x => x.SalesValueTotal == 0 || x.StandardCost == 0)
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeHeader(string value)
|
||||
{
|
||||
var chars = value
|
||||
.ToLowerInvariant()
|
||||
.Where(char.IsLetterOrDigit)
|
||||
.ToArray();
|
||||
return new string(chars);
|
||||
}
|
||||
|
||||
private static string GetText(IXLRangeRow row, IReadOnlyDictionary<string, int> headers, string key)
|
||||
=> headers.TryGetValue(key, out var index) ? row.Cell(index).GetString().Trim() : string.Empty;
|
||||
|
||||
private static decimal GetDecimal(IXLRangeRow row, IReadOnlyDictionary<string, int> headers, string key)
|
||||
{
|
||||
if (!headers.TryGetValue(key, out var index))
|
||||
return 0m;
|
||||
|
||||
var text = row.Cell(index).GetFormattedString().Trim();
|
||||
if (decimal.TryParse(text, out var direct))
|
||||
return direct;
|
||||
if (decimal.TryParse(text, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var invariant))
|
||||
return invariant;
|
||||
if (decimal.TryParse(text, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.GetCultureInfo("de-CH"), out var local))
|
||||
return local;
|
||||
return 0m;
|
||||
}
|
||||
|
||||
private static DateTime? GetDate(IXLRangeRow row, IReadOnlyDictionary<string, int> headers, string key)
|
||||
{
|
||||
if (!headers.TryGetValue(key, out var index))
|
||||
return null;
|
||||
|
||||
var cell = row.Cell(index);
|
||||
if (cell.DataType == XLDataType.DateTime)
|
||||
return cell.GetDateTime();
|
||||
|
||||
var text = cell.GetString().Trim();
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return null;
|
||||
|
||||
if (DateTime.TryParse(text, out var direct))
|
||||
return direct;
|
||||
if (DateTime.TryParse(text, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeLocal, out var invariant))
|
||||
return invariant;
|
||||
if (DateTime.TryParse(text, System.Globalization.CultureInfo.GetCultureInfo("de-CH"), System.Globalization.DateTimeStyles.AssumeLocal, out var local))
|
||||
return local;
|
||||
return null;
|
||||
}
|
||||
|
||||
private class CockpitRow
|
||||
{
|
||||
public DateTime? ExtractionDate { get; set; }
|
||||
public string Tsc { get; set; } = string.Empty;
|
||||
public string InvoiceNumber { get; set; } = string.Empty;
|
||||
public string PositionOnInvoice { get; set; } = string.Empty;
|
||||
public string Material { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string ProductGroup { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; }
|
||||
public string SupplierNumber { get; set; } = string.Empty;
|
||||
public string SupplierName { get; set; } = string.Empty;
|
||||
public string SupplierCountry { get; set; } = string.Empty;
|
||||
public string CustomerNumber { get; set; } = string.Empty;
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string CustomerCountry { get; set; } = string.Empty;
|
||||
public string CustomerIndustry { get; set; } = string.Empty;
|
||||
public decimal StandardCost { get; set; }
|
||||
public string StandardCostCurrency { get; set; } = string.Empty;
|
||||
public decimal SalesValueTotal { get; set; }
|
||||
public string SalesCurrency { get; set; } = string.Empty;
|
||||
public string Incoterms2020 { get; set; } = string.Empty;
|
||||
public string SalesResponsibleEmployee { get; set; } = string.Empty;
|
||||
public DateTime? InvoiceDate { get; set; }
|
||||
public DateTime? OrderDate { get; set; }
|
||||
public string Land { get; set; } = string.Empty;
|
||||
public decimal EstimatedCostTotal { get; set; }
|
||||
public decimal EstimatedMarginTotal { get; set; }
|
||||
public decimal AggregatedValue { get; set; }
|
||||
public string AggregatedCurrency { get; set; } = string.Empty;
|
||||
public bool MissingExchangeRate { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user