Add Custom Powder Order line item and fix CSV import FinalPrice crash
Custom powder/incoming powder material cost now flows into a separate auto-generated 'Custom Powder Order' line item instead of rolling into individual item prices, so users can add shipping charges before the customer sees the total. A dashed yellow preview card in the wizard shows the material cost and lets users edit the total (including shipping) before saving. After first save the price is user-owned. Also fixes a fatal CSV import crash when FinalPrice contains a non-numeric value (e.g. 'false' from a spreadsheet formula): the job CSV importer now streams rows one at a time with a lenient decimal converter, treating bad values as $0 with a per-row warning instead of aborting the entire import. Updated HelpKnowledgeBase.cs and Help articles (Jobs, Quotes) with Custom Powder Order behavior and a new Data Import / Export section. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1268,24 +1268,22 @@ public class CsvImportService : ICsvImportService
|
||||
MissingFieldFound = null
|
||||
});
|
||||
|
||||
var records = csv.GetRecords<JobImportDto>().ToList();
|
||||
result.TotalRows = records.Count;
|
||||
// Treat non-numeric values in decimal? fields (e.g. a spreadsheet "false" in FinalPrice)
|
||||
// as null rather than throwing a fatal TypeConverterException.
|
||||
csv.Context.TypeConverterCache.AddConverter<decimal?>(new LenientNullableDecimalConverter());
|
||||
|
||||
_logger.LogInformation("Starting import of {Count} jobs for company {CompanyId}", records.Count, companyId);
|
||||
// Read header row first so we know field count before iterating rows.
|
||||
await csv.ReadAsync();
|
||||
csv.ReadHeader();
|
||||
|
||||
// Get all existing jobs for duplicate detection
|
||||
// Pre-load lookup data before streaming rows so async calls don't interleave with CSV reads.
|
||||
var existingJobs = await _unitOfWork.Jobs.GetAllAsync();
|
||||
var existingJobNumbers = existingJobs.Where(j => !string.IsNullOrEmpty(j.JobNumber))
|
||||
.ToDictionary(j => j.JobNumber.ToUpper(), j => j, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Get customers for lookup — build two dictionaries so we can resolve by email
|
||||
// first and fall back to company name when the customer has no email on file.
|
||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
||||
var customerByEmail = customers.Where(c => !string.IsNullOrEmpty(c.Email))
|
||||
.ToDictionary(c => c.Email!.Trim().ToLower(), c => c, StringComparer.OrdinalIgnoreCase);
|
||||
// Name fallback: keyed on CompanyName (commercial) or "First Last" (non-commercial).
|
||||
// TryAdd ensures that if two customers share the same name the first one wins and the
|
||||
// lookup warning will prompt the user to resolve the ambiguity manually.
|
||||
var customerByName = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var c in customers)
|
||||
{
|
||||
@@ -1296,19 +1294,42 @@ public class CsvImportService : ICsvImportService
|
||||
customerByName.TryAdd(name, c);
|
||||
}
|
||||
|
||||
// Get job statuses for lookup
|
||||
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
||||
var jobStatusDict = jobStatuses.ToDictionary(js => js.StatusCode.ToUpper(), js => js, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Get job priorities for lookup
|
||||
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
|
||||
var jobPriorityDict = jobPriorities.ToDictionary(jp => jp.PriorityCode.ToUpper(), jp => jp, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var jobsToImport = new List<(int RowNumber, Job Job, string JobNumber)>();
|
||||
|
||||
foreach (var record in records)
|
||||
// Stream rows one at a time so a bad type conversion on a single row (e.g. "false"
|
||||
// in a decimal field) is caught per-row rather than aborting the entire import.
|
||||
while (await csv.ReadAsync())
|
||||
{
|
||||
rowNumber++;
|
||||
result.TotalRows++;
|
||||
JobImportDto record;
|
||||
try
|
||||
{
|
||||
record = csv.GetRecord<JobImportDto>()
|
||||
?? throw new InvalidOperationException("Row returned null record.");
|
||||
}
|
||||
catch (Exception parseEx)
|
||||
{
|
||||
result.Errors.Add($"Row {csv.Context.Parser?.Row}: Could not parse row - {parseEx.InnerException?.Message ?? parseEx.Message}");
|
||||
result.ErrorCount++;
|
||||
_logger.LogWarning(parseEx, "Parse error at CSV row {Row}", csv.Context.Parser?.Row);
|
||||
continue;
|
||||
}
|
||||
|
||||
rowNumber = csv.Context.Parser?.Row ?? rowNumber + 1;
|
||||
|
||||
// Warn when FinalPrice was non-numeric (lenient converter returned null).
|
||||
var rawFinalPrice = csv.TryGetField<string>(7, out var fpStr) ? fpStr : null;
|
||||
if (!string.IsNullOrWhiteSpace(rawFinalPrice) && record.FinalPrice == null
|
||||
&& !decimal.TryParse(rawFinalPrice, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out _))
|
||||
{
|
||||
result.Warnings.Add($"Row {rowNumber}: FinalPrice value '{rawFinalPrice}' could not be parsed as a number; defaulting to $0.");
|
||||
}
|
||||
try
|
||||
{
|
||||
// Validate required fields
|
||||
@@ -3340,4 +3361,23 @@ public class CsvImportService : ICsvImportService
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns null for any value that cannot be parsed as a decimal, instead of throwing a
|
||||
/// TypeConverterException. Registered globally on the job CSV reader so that spreadsheet
|
||||
/// artefacts like "false" in a price column are treated as $0 with a warning.
|
||||
/// </summary>
|
||||
private sealed class LenientNullableDecimalConverter : CsvHelper.TypeConversion.ITypeConverter
|
||||
{
|
||||
public object? ConvertFromString(string? text, CsvHelper.IReaderRow row, CsvHelper.Configuration.MemberMapData memberMapData)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||
return decimal.TryParse(text, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var v)
|
||||
? (object?)v
|
||||
: null;
|
||||
}
|
||||
|
||||
public string? ConvertToString(object? value, CsvHelper.IWriterRow row, CsvHelper.Configuration.MemberMapData memberMapData)
|
||||
=> value?.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user