Add rework pricing type (Fixed vs Per-Item) and inline rework flow on Job Details

Adds a PricingType enum to ReworkRecord (FixedPrice | PerItem), surfaces the
choice in the rework modal on Job Details, and wires the resulting unit/total
price display. Includes migration AddReworkPricingType, updated repository
query for rework history, and help article updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 09:27:34 -04:00
parent 15b070398b
commit f018653c18
13 changed files with 11143 additions and 169 deletions
@@ -486,6 +486,7 @@ public class ReworkRecordDto
public decimal ActualReworkCost { get; set; }
public bool IsBillableToCustomer { get; set; }
public string? BillingNotes { get; set; }
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
public PowderCoating.Core.Enums.ReworkStatus Status { get; set; }
public string StatusDisplay { get; set; } = string.Empty;
@@ -511,6 +512,11 @@ public class CreateReworkRecordDto
public decimal EstimatedReworkCost { get; set; }
public bool IsBillableToCustomer { get; set; }
public string? BillingNotes { get; set; }
// Rework job creation (opt-in)
public bool CreateReworkJob { get; set; }
public List<int>? ReworkJobItemIds { get; set; } // null = not creating a job
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
}
public class UpdateReworkRecordDto
@@ -196,7 +196,9 @@ public class JobProfile : Profile
.ForMember(dest => dest.JobItemDescription,
opt => opt.MapFrom(src => src.JobItem != null ? src.JobItem.Description : null))
.ForMember(dest => dest.ReworkJobNumber,
opt => opt.MapFrom(src => src.ReworkJob != null ? src.ReworkJob.JobNumber : null));
opt => opt.MapFrom(src => src.ReworkJob != null ? src.ReworkJob.JobNumber : null))
.ForMember(dest => dest.ReworkPricingType,
opt => opt.MapFrom(src => src.ReworkPricingType));
// Job → JobDto (rework fields)
// (IsReworkJob and OriginalJobId map by convention; OriginalJobNumber needs explicit map — handled in controller)
@@ -31,6 +31,9 @@ public class ReworkRecord : BaseEntity
public bool IsBillableToCustomer { get; set; }
public string? BillingNotes { get; set; }
// Pricing attribution for the linked rework job (null on pre-existing records)
public ReworkPricingType? ReworkPricingType { get; set; }
// ── Resolution ────────────────────────────────────────────────────────────
public ReworkStatus Status { get; set; } = ReworkStatus.Open;
public ReworkResolution? Resolution { get; set; }
+8
View File
@@ -144,6 +144,14 @@ public enum ReworkResolution
NoActionRequired = 4
}
/// <summary>Who bears the cost of the rework job, recorded at the time the rework is logged.</summary>
public enum ReworkPricingType
{
ShopFault = 0, // Redo is on the shop — rework job items priced at $0
CustomerReduced = 1, // Customer caused it; we're helping — prices copied, user edits
CustomerFull = 2 // Customer caused it; full original pricing applies
}
public enum BugReportStatus
{
New = 0,
@@ -92,4 +92,10 @@ public interface IJobRepository : IRepository<Job>
/// were never completed and rolled past their scheduled day.
/// </summary>
Task<List<Job>> GetOverdueScheduledJobsAsync();
/// <summary>
/// Returns the count of rework jobs linked to <paramref name="originalJobId"/>
/// (including soft-deleted) so the next rework suffix (R1, R2, …) can be determined.
/// </summary>
Task<int> GetReworkJobCountAsync(int originalJobId);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddReworkPricingType : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ReworkPricingType",
table: "ReworkRecords",
type: "int",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8533));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8542));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8543));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ReworkPricingType",
table: "ReworkRecords");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191));
}
}
}
@@ -6711,7 +6711,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186),
CreatedAt = new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8533),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -6722,7 +6722,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190),
CreatedAt = new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8542),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -6733,7 +6733,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191),
CreatedAt = new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8543),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -7990,6 +7990,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int?>("ReworkJobId")
.HasColumnType("int");
b.Property<int?>("ReworkPricingType")
.HasColumnType("int");
b.Property<int>("ReworkType")
.HasColumnType("int");
@@ -187,6 +187,14 @@ public class JobRepository : Repository<Job>, IJobRepository
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<int> GetReworkJobCountAsync(int originalJobId)
{
return await _context.Jobs
.IgnoreQueryFilters()
.CountAsync(j => j.OriginalJobId == originalJobId);
}
/// <inheritdoc/>
public async Task<List<Job>> GetOverdueScheduledJobsAsync()
{
@@ -2407,6 +2407,28 @@ public class JobsController : Controller
});
}
// When a rework job reaches a terminal status, close out the linked ReworkRecord
// on the original job so the shop doesn't have to do it manually.
// Cancelled → WrittenOff; any other terminal → Resolved.
if (newStatus?.IsTerminalStatus == true && job.IsReworkJob)
{
var linkedRecords = await _unitOfWork.ReworkRecords.FindAsync(
r => r.ReworkJobId == job.Id && r.CompanyId == job.CompanyId, false);
foreach (var rr in linkedRecords)
{
if (rr.Status == ReworkStatus.Resolved || rr.Status == ReworkStatus.WrittenOff)
continue;
rr.Status = newStatus.StatusCode == AppConstants.StatusCodes.Job.Cancelled
? ReworkStatus.WrittenOff
: ReworkStatus.Resolved;
rr.ResolvedDate ??= DateTime.UtcNow;
rr.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.ReworkRecords.UpdateAsync(rr);
}
if (linkedRecords.Any())
await _unitOfWork.SaveChangesAsync();
}
// Notify customer on status change (only if user opted in)
if (request.SendEmail && newStatus != null)
{
@@ -3528,10 +3550,13 @@ public class JobsController : Controller
}
/// <summary>
/// Records a rework event against a job item (e.g. defect found during QC).
/// Automatically creates a new linked rework Job so the repair work can be tracked
/// through the same job lifecycle. The rework job inherits the original job's customer,
/// oven, and items so the shop has a complete specification to work from.
/// Records a rework event against a job. Optionally creates a linked rework job so the
/// repair can flow through the full shop lifecycle. When creating a rework job:
/// - Job number uses sub-number format: {parentNumber}-R{n} (e.g. JOB-2605-0007-R1)
/// - Only items selected by the user are copied (partial rework support)
/// - Pricing obeys the ReworkPricingType: ShopFault zeros all item prices;
/// CustomerReduced/CustomerFull copy prices as-is (user edits after if needed)
/// - Job starts at the first non-Pending status in the company's workflow
/// </summary>
[HttpPost]
public async Task<IActionResult> AddReworkRecord([FromBody] CreateReworkRecordDto dto)
@@ -3540,66 +3565,41 @@ public class JobsController : Controller
if (job == null) return NotFound();
var companyId = job.CompanyId;
Job? reworkJob = null;
// Generate rework job number
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
var allJobs = await _unitOfWork.Jobs.GetAllAsync(true);
var year = DateTime.Now.ToString("yy");
var month = DateTime.Now.ToString("MM");
var prefix = $"JOB-{year}{month}-";
var maxNum = allJobs
.Where(j => j.JobNumber.StartsWith(prefix))
.Select(j => { int.TryParse(j.JobNumber.Replace(prefix, ""), out int n); return n; })
.DefaultIfEmpty(0).Max();
var reworkJob = pendingStatus != null ? new Job
if (dto.CreateReworkJob && dto.ReworkJobItemIds != null && dto.ReworkJobItemIds.Count > 0 && dto.ReworkPricingType.HasValue)
{
JobNumber = $"{prefix}{(maxNum + 1):D4}",
CustomerId = job.CustomerId,
Description = $"REWORK: {job.Description}",
JobStatusId = pendingStatus.Id,
JobPriorityId = normalPriority.Id,
IsReworkJob = true,
OriginalJobId = job.Id,
SpecialInstructions = $"Rework of {job.JobNumber}.",
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
} : null;
if (reworkJob != null)
var typeLabel = dto.ReworkType switch
{
await _unitOfWork.Jobs.AddAsync(reworkJob);
await _unitOfWork.CompleteAsync();
// Copy items: specific item if flagged, otherwise all items
var itemsToCopy = dto.JobItemId.HasValue
? job.JobItems.Where(i => i.Id == dto.JobItemId.Value).ToList()
: job.JobItems.ToList();
foreach (var item in itemsToCopy)
ReworkType.InternalDefect => "Internal Defect",
ReworkType.CustomerWarranty => "Customer Warranty",
ReworkType.CustomerDamage => "Customer Damage",
_ => dto.ReworkType.ToString()
};
var reasonLabel = dto.Reason switch
{
var createdAtUtc = DateTime.UtcNow;
var newItem = _jobItemAssemblyService.CreateJobItem(item, reworkJob.Id, companyId, createdAtUtc);
await _unitOfWork.JobItems.AddAsync(newItem);
await _unitOfWork.CompleteAsync();
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(item, newItem.Id, companyId, createdAtUtc))
ReworkReason.AdhesionFailure => "Adhesion Failure",
ReworkReason.Contamination => "Contamination",
ReworkReason.ColorMismatch => "Color Mismatch",
ReworkReason.RunsSags => "Runs / Sags",
ReworkReason.SurfacePrepFailure => "Surface Prep Failure",
ReworkReason.OvenIssue => "Oven Issue",
ReworkReason.InsufficientCoverage => "Insufficient Coverage",
ReworkReason.HandlingDamage => "Handling Damage",
_ => "Other"
};
var pricingLabel = dto.ReworkPricingType.Value switch
{
await _unitOfWork.JobItemCoats.AddAsync(coat);
}
ReworkPricingType.ShopFault => "Shop Fault — no charge",
ReworkPricingType.CustomerReduced => "Customer responsible — reduced rate",
ReworkPricingType.CustomerFull => "Customer responsible — full price",
_ => ""
};
var defect = string.IsNullOrWhiteSpace(dto.DefectDescription) ? "" : $": {dto.DefectDescription}";
var reworkDescription = $"REWORK ({typeLabel} / {reasonLabel}){defect}. Pricing: {pricingLabel}.";
foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(item, newItem.Id, companyId, createdAtUtc))
{
await _unitOfWork.JobItemPrepServices.AddAsync(prepService);
}
}
await _unitOfWork.CompleteAsync();
var currentUserId = _userManager.GetUserId(User);
reworkJob = await BuildReworkJobAsync(job, dto.ReworkJobItemIds, dto.ReworkPricingType.Value, companyId, reworkDescription, currentUserId);
}
var record = new ReworkRecord
@@ -3615,6 +3615,7 @@ public class JobsController : Controller
EstimatedReworkCost = dto.EstimatedReworkCost,
IsBillableToCustomer = dto.IsBillableToCustomer,
BillingNotes = dto.BillingNotes,
ReworkPricingType = dto.ReworkPricingType,
ReworkJobId = reworkJob?.Id,
Status = reworkJob != null ? ReworkStatus.InProgress : ReworkStatus.Open,
CompanyId = companyId,
@@ -3624,11 +3625,147 @@ public class JobsController : Controller
await _unitOfWork.ReworkRecords.AddAsync(record);
await _unitOfWork.CompleteAsync();
// Reload with navigation for response
var saved = await _unitOfWork.ReworkRecords.FindAsync(r => r.Id == record.Id, false, r => r.JobItem, r => r.ReworkJob);
return Json(_mapper.Map<ReworkRecordDto>(saved.First()));
}
/// <summary>
/// Creates a linked rework Job from an existing rework record that was saved without one.
/// Uses sub-number format and applies the specified pricing attribution.
/// </summary>
[HttpPost]
public async Task<IActionResult> CreateReworkJob([FromBody] CreateReworkJobRequest req)
{
var reworkRecord = await _unitOfWork.ReworkRecords.GetByIdAsync(req.ReworkRecordId, false, r => r.Job);
if (reworkRecord == null) return NotFound();
var originalJob = await _unitOfWork.Jobs.LoadForDetailsAsync(reworkRecord.JobId);
if (originalJob == null) return NotFound();
var companyId = originalJob.CompanyId;
var itemIds = req.ItemIds ?? originalJob.JobItems.Select(i => i.Id).ToList();
var pricingType = req.ReworkPricingType ?? ReworkPricingType.ShopFault;
var pricingLabel = pricingType switch
{
ReworkPricingType.ShopFault => "Shop Fault — no charge",
ReworkPricingType.CustomerReduced => "Customer responsible — reduced rate",
ReworkPricingType.CustomerFull => "Customer responsible — full price",
_ => ""
};
var notes = string.IsNullOrWhiteSpace(req.Notes) ? "" : $" Notes: {req.Notes}";
var reworkDescription = $"REWORK: {pricingLabel}.{notes}";
var currentUserId = _userManager.GetUserId(User);
var reworkJob = await BuildReworkJobAsync(originalJob, itemIds, pricingType, companyId, reworkDescription, currentUserId);
reworkRecord.ReworkJobId = reworkJob.Id;
reworkRecord.ReworkPricingType = pricingType;
reworkRecord.Status = ReworkStatus.InProgress;
reworkRecord.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.ReworkRecords.UpdateAsync(reworkRecord);
await _unitOfWork.CompleteAsync();
return Json(new { success = true, reworkJobId = reworkJob.Id, reworkJobNumber = reworkJob.JobNumber });
}
/// <summary>
/// Shared helper that creates and persists a rework Job with sub-numbered job number,
/// copies the specified items (with coats and prep services), applies pricing attribution,
/// sets descriptive job description from the rework record data, and auto-records intake
/// (parts are already on hand when rework is logged).
/// Called by both AddReworkRecord and CreateReworkJob.
/// </summary>
private async Task<Job> BuildReworkJobAsync(
Job originalJob,
List<int> itemIds,
ReworkPricingType pricingType,
int companyId,
string reworkDescription,
string? checkedByUserId)
{
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
// First non-Pending status by workflow order
var firstActiveStatus = statuses
.Where(s => s.StatusCode != AppConstants.StatusCodes.Job.Pending)
.OrderBy(s => s.DisplayOrder)
.First();
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
// Sub-number: {parentJobNumber}-R{n+1}
var reworkCount = await _unitOfWork.Jobs.GetReworkJobCountAsync(originalJob.Id);
var reworkNumber = $"{originalJob.JobNumber}-R{reworkCount + 1}";
var reworkJob = new Job
{
JobNumber = reworkNumber,
CustomerId = originalJob.CustomerId,
Description = reworkDescription,
JobStatusId = firstActiveStatus.Id,
JobPriorityId = normalPriority.Id,
IsReworkJob = true,
OriginalJobId = originalJob.Id,
SpecialInstructions = $"Rework of {originalJob.JobNumber}.",
// Auto-intake: parts are already on hand when rework is logged
IntakeDate = DateTime.UtcNow,
IntakeConditionNotes = $"Parts auto-checked in as rework from {originalJob.JobNumber}.",
IntakeCheckedByUserId = checkedByUserId,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.Jobs.AddAsync(reworkJob);
await _unitOfWork.CompleteAsync();
var itemsToCopy = originalJob.JobItems.Where(i => itemIds.Contains(i.Id)).ToList();
var createdAtUtc = DateTime.UtcNow;
foreach (var item in itemsToCopy)
{
var newItem = _jobItemAssemblyService.CreateJobItem(item, reworkJob.Id, companyId, createdAtUtc);
// Shop-fault rework jobs are done at no charge
if (pricingType == ReworkPricingType.ShopFault)
{
newItem.UnitPrice = 0;
newItem.ManualUnitPrice = 0;
newItem.TotalPrice = 0;
}
await _unitOfWork.JobItems.AddAsync(newItem);
await _unitOfWork.CompleteAsync();
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(item, newItem.Id, companyId, createdAtUtc))
await _unitOfWork.JobItemCoats.AddAsync(coat);
foreach (var prep in _jobItemAssemblyService.CreateJobItemPrepServices(item, newItem.Id, companyId, createdAtUtc))
await _unitOfWork.JobItemPrepServices.AddAsync(prep);
}
// Set intake part count now that items are known
reworkJob.IntakePartCount = (int)Math.Ceiling(itemsToCopy.Sum(i => i.Quantity));
// Write a pricing snapshot so the Details page and inline edit both work correctly
var itemsSubtotal = pricingType == ReworkPricingType.ShopFault
? 0m
: itemsToCopy.Sum(i => i.TotalPrice);
reworkJob.FinalPrice = itemsSubtotal;
reworkJob.PricingBreakdownJson = System.Text.Json.JsonSerializer.Serialize(new QuotePricingBreakdownDto
{
ItemsSubtotal = itemsSubtotal,
SubtotalBeforeDiscount = itemsSubtotal,
SubtotalAfterDiscount = itemsSubtotal,
Total = itemsSubtotal
});
await _unitOfWork.Jobs.UpdateAsync(reworkJob);
await _unitOfWork.CompleteAsync();
return reworkJob;
}
/// <summary>
/// Updates a rework record's status, resolution notes, cost, and billability.
/// Auto-sets ResolvedDate when status transitions to Resolved or WrittenOff (if not already set).
@@ -3680,66 +3817,6 @@ public class JobsController : Controller
return Json(new { success = true });
}
/// <summary>
/// Creates a new rework Job from an existing rework record and links them.
/// The rework job is a lightweight clone of the original job — same customer, description, and
/// oven — but starts fresh with Pending status so it goes through the full workflow again.
/// The ReworkJob FK on the rework record is updated so the Detail view can link to it.
/// </summary>
[HttpPost]
public async Task<IActionResult> CreateReworkJob([FromBody] CreateReworkJobRequest req)
{
var reworkRecord = await _unitOfWork.ReworkRecords.GetByIdAsync(req.ReworkRecordId, false, r => r.Job);
if (reworkRecord == null) return NotFound();
var originalJob = reworkRecord.Job;
var companyId = originalJob.CompanyId;
// Load status lookups to find Pending status
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
if (pendingStatus == null) return Json(new { success = false, message = "Could not find Pending status." });
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
// Generate job number
var allJobs = await _unitOfWork.Jobs.GetAllAsync(true);
var year = DateTime.Now.ToString("yy");
var month = DateTime.Now.ToString("MM");
var prefix = $"JOB-{year}{month}-";
var maxNum = allJobs
.Where(j => j.JobNumber.StartsWith(prefix))
.Select(j => { int.TryParse(j.JobNumber.Replace(prefix, ""), out int n); return n; })
.DefaultIfEmpty(0).Max();
var reworkJob = new Job
{
JobNumber = $"{prefix}{(maxNum + 1):D4}",
CustomerId = originalJob.CustomerId,
Description = $"REWORK: {originalJob.Description}",
JobStatusId = pendingStatus.Id,
JobPriorityId = normalPriority.Id,
IsReworkJob = true,
OriginalJobId = originalJob.Id,
SpecialInstructions = $"Rework of {originalJob.JobNumber}. {req.Notes}".Trim().TrimEnd('.') + ".",
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.Jobs.AddAsync(reworkJob);
await _unitOfWork.CompleteAsync();
// Link rework record to new job
reworkRecord.ReworkJobId = reworkJob.Id;
reworkRecord.Status = ReworkStatus.InProgress;
reworkRecord.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.ReworkRecords.UpdateAsync(reworkRecord);
await _unitOfWork.CompleteAsync();
return Json(new { success = true, reworkJobId = reworkJob.Id, reworkJobNumber = reworkJob.JobNumber });
}
// ── Quote-Changed Banner Actions ──────────────────────────────────────────
/// <summary>
@@ -4311,7 +4388,13 @@ public class LogMaterialRequest
public string TransactionType { get; set; } = "JobUsage";
public string? Notes { get; set; }
}
public class CreateReworkJobRequest { public int ReworkRecordId { get; set; } public string? Notes { get; set; } }
public class CreateReworkJobRequest
{
public int ReworkRecordId { get; set; }
public List<int>? ItemIds { get; set; }
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
public string? Notes { get; set; }
}
public class UpdateWorkerAssignmentRequest
{
+34 -5
View File
@@ -475,12 +475,41 @@
actual hours vs. estimated hours for costing and productivity analysis.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Rework</h3>
<h3 class="h6 fw-semibold mt-3 mb-2">Rework (also called Redo)</h3>
<p>
If finished parts fail quality inspection or need to be re-coated, create a rework record
from the Job Details page. Rework records track the rework type, the reason (adhesion failure,
color mismatch, damage, etc.), and the resolution. This data helps identify recurring quality
issues over time.
If a finished part fails quality inspection or a customer returns it damaged, open the original
job&rsquo;s Details page and use the <strong>Rework Log</strong> section to record it. Rework
and redo mean the same thing throughout the system.
</p>
<p>Each entry captures the type (internal defect, customer damage, warranty), the reason (adhesion
failure, color mismatch, runs/sags, insufficient coverage, etc.), a defect description, who
discovered the issue, and pricing responsibility.</p>
<h4 class="h6 fw-semibold mt-3 mb-1">Pricing Responsibility</h4>
<ul>
<li><strong>Shop Fault &mdash; no charge:</strong> All copied item prices are set to $0.</li>
<li><strong>Customer responsible &mdash; reduced rate:</strong> Prices are copied from the original job; edit them down after creation.</li>
<li><strong>Customer responsible &mdash; full price:</strong> Prices are copied as-is.</li>
</ul>
<h4 class="h6 fw-semibold mt-3 mb-1">Creating a Rework Job</h4>
<p>
Toggle <strong>Parts are back &mdash; create a Rework Job</strong> at the top of the log form.
Select the items that need to be redone and choose the pricing responsibility. The system will:
</p>
<ul>
<li>Create a new job with a sub-number (e.g., <code>JOB-2605-0001-R1</code>)</li>
<li>Copy the selected items with their coats and prep services</li>
<li>Auto-record intake &mdash; parts are already on hand when rework is logged</li>
<li>Set the job description to the defect type, reason, and pricing so it is visible at the top of the job</li>
</ul>
<h4 class="h6 fw-semibold mt-3 mb-1">Automatic Resolution</h4>
<p>
When the rework job reaches a terminal status (Completed, Delivered, etc.), the linked rework
record on the original job is automatically marked <strong>Resolved</strong> &mdash; no manual
follow-up needed. If the rework job is <strong>Cancelled</strong>, the record is marked
<strong>Written Off</strong> instead.
</p>
</section>
+130 -18
View File
@@ -28,6 +28,20 @@
</div>
</div>
@if (Model.IsReworkJob && Model.OriginalJobId.HasValue)
{
<div class="alert alert-warning alert-permanent d-flex align-items-center gap-3 mb-4">
<i class="bi bi-arrow-repeat fs-5 flex-shrink-0"></i>
<div>
<strong>Rework Job</strong> &mdash; This job was created to redo work from
<a asp-action="Details" asp-route-id="@Model.OriginalJobId" class="alert-link fw-semibold">
@(Model.OriginalJobNumber ?? $"Job #{Model.OriginalJobId}")
</a>.
All costs for this redo are tracked here separately from the original job.
</div>
</div>
}
<!-- Status Banner -->
<div class="alert alert-@Model.StatusColorClass alert-permanent d-flex align-items-center mb-4">
<i class="bi bi-info-circle me-2" style="font-size: 1.5rem;"></i>
@@ -2189,6 +2203,88 @@
<div class="modal-body">
<div id="reworkAddForm">
<div class="row g-3">
<!-- Step 1: Are parts back in the shop? -->
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="rwCreateJobToggle" onchange="rework.toggleCreateJob(this.checked)" />
<label class="form-check-label fw-semibold" for="rwCreateJobToggle">
<i class="bi bi-briefcase me-1"></i>Parts are back &mdash; create a Rework Job in the shop
</label>
<div class="text-muted small">Turn this on if the parts are physically in the shop and need to go back through the workflow.</div>
</div>
</div>
<!-- Item selection: checkboxes when creating a job, single dropdown otherwise -->
<div id="rwCreateJobOptions" style="display:none;" class="col-12">
<div class="border rounded p-3 bg-light">
<div class="mb-3">
<label class="form-label fw-semibold mb-1">Which items need to be redone? <span class="text-danger">*</span></label>
<div class="text-muted small mb-2">Only checked items will be copied to the rework job.</div>
<div id="rwItemCheckboxes">
@if (Model.Items != null)
{
@foreach (var item in Model.Items)
{
<div class="form-check">
<input class="form-check-input rw-item-cb" type="checkbox" value="@item.Id" id="rwItem_@item.Id" />
<label class="form-check-label" for="rwItem_@item.Id">@item.Description</label>
</div>
}
}
</div>
</div>
<div>
<label class="form-label fw-semibold mb-1">Who is responsible? <span class="text-danger">*</span></label>
<div class="row g-2">
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="radio" name="rwPricingType" id="rwPricingShopFault" value="0" />
<label class="form-check-label" for="rwPricingShopFault">
<strong>Shop Fault</strong>
<span class="text-muted small d-block">Our mistake &mdash; rework job priced at $0.</span>
</label>
</div>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="radio" name="rwPricingType" id="rwPricingCustomerReduced" value="1" />
<label class="form-check-label" for="rwPricingCustomerReduced">
<strong>Customer &mdash; Reduced Rate</strong>
<span class="text-muted small d-block">Customer caused it but we&rsquo;re helping out &mdash; prices copied, edit after creation.</span>
</label>
</div>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="radio" name="rwPricingType" id="rwPricingCustomerFull" value="2" />
<label class="form-check-label" for="rwPricingCustomerFull">
<strong>Customer &mdash; Full Price</strong>
<span class="text-muted small d-block">Customer caused it &mdash; original pricing applies.</span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="rwSpecificItemRow" class="col-md-6">
<label class="form-label">Specific Item (optional)</label>
<select class="form-select" id="rwJobItem">
<option value="">&ndash; Whole Job &ndash;</option>
@if (Model.Items != null)
{
@foreach (var item in Model.Items)
{
<option value="@item.Id">@item.Description</option>
}
}
</select>
</div>
<hr class="my-0" />
<div class="col-md-6">
<label class="form-label">Type <span class="text-danger">*</span></label>
<select class="form-select" id="rwType">
@@ -2215,19 +2311,6 @@
<label class="form-label">Defect Description <span class="text-danger">*</span></label>
<textarea class="form-control" id="rwDefect" rows="2" placeholder="Describe the defect or issue..."></textarea>
</div>
<div class="col-md-6">
<label class="form-label">Specific Item (optional)</label>
<select class="form-select" id="rwJobItem">
<option value="">&ndash; Whole Job &ndash;</option>
@if (Model.Items != null)
{
@foreach (var item in Model.Items)
{
<option value="@item.Id">@item.Description</option>
}
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Discovered By</label>
<select class="form-select" id="rwDiscoveredBy">
@@ -2654,9 +2737,20 @@
document.getElementById('rwBillingNotes').value = '';
document.getElementById('rwReportedBy').value = '';
document.getElementById('rwDiscoveredDate').value = new Date().toISOString().split('T')[0];
// Reset rework job creation section
document.getElementById('rwCreateJobToggle').checked = false;
document.getElementById('rwCreateJobOptions').style.display = 'none';
document.querySelectorAll('.rw-item-cb').forEach(cb => cb.checked = false);
document.querySelectorAll('input[name="rwPricingType"]').forEach(r => r.checked = false);
modal.show();
}
function toggleCreateJob(on) {
document.getElementById('rwCreateJobOptions').style.display = on ? '' : 'none';
document.getElementById('rwSpecificItemRow').style.display = on ? 'none' : '';
if (!on) document.getElementById('rwJobItem').value = '';
}
function openEdit(id) {
editId = id;
const r = records.find(x => x.id === id);
@@ -2685,9 +2779,23 @@
// Create
const defect = document.getElementById('rwDefect').value.trim();
if (!defect) { alert('Defect description is required.'); return; }
const createJob = document.getElementById('rwCreateJobToggle').checked;
const selectedItemIds = createJob
? Array.from(document.querySelectorAll('.rw-item-cb:checked')).map(cb => parseInt(cb.value))
: null;
const pricingRadio = document.querySelector('input[name="rwPricingType"]:checked');
if (createJob && (!selectedItemIds || selectedItemIds.length === 0)) {
alert('Select at least one item to include in the rework job.'); return;
}
if (createJob && !pricingRadio) {
alert('Select who is responsible for this rework.'); return;
}
const dto = {
jobId: jid,
jobItemId: document.getElementById('rwJobItem').value || null,
jobItemId: createJob ? null : (document.getElementById('rwJobItem').value || null),
reworkType: parseInt(document.getElementById('rwType').value),
reason: parseInt(document.getElementById('rwReason').value),
defectDescription: defect,
@@ -2696,7 +2804,10 @@
reportedByName: document.getElementById('rwReportedBy').value || null,
estimatedReworkCost: parseFloat(document.getElementById('rwEstCost').value) || 0,
isBillableToCustomer: document.getElementById('rwBillable').checked,
billingNotes: document.getElementById('rwBillingNotes').value || null
billingNotes: document.getElementById('rwBillingNotes').value || null,
createReworkJob: createJob,
reworkJobItemIds: selectedItemIds,
reworkPricingType: pricingRadio ? parseInt(pricingRadio.value) : null
};
const resp = await fetch('/Jobs/AddReworkRecord', {
method: 'POST',
@@ -2741,7 +2852,7 @@
}
load();
return { load, openAdd, openEdit, save, del };
return { load, openAdd, openEdit, save, del, toggleCreateJob };
})();
</script>
@@ -2906,8 +3017,8 @@
function updateTotals(total) {
const fmt = total > 0 ? total.toFixed(2) + ' hrs' : '&mdash;';
document.getElementById('totalHoursDisplay').textContent = fmt;
document.getElementById('timeEntriesTotalHours').textContent = total > 0 ? total.toFixed(2) : '&mdash;';
document.getElementById('totalHoursDisplay').innerHTML = fmt;
document.getElementById('timeEntriesTotalHours').innerHTML = total > 0 ? total.toFixed(2) : '&mdash;';
}
// -- Modal helpers -------------------------------------------------
@@ -3245,3 +3356,4 @@
</div>
</div>
</div>
@@ -150,13 +150,18 @@ public class QuoteAndReworkControllerFlowTests
DefectDescription = "Thin coverage on one edge",
DiscoveredBy = ReworkDiscoveredBy.Internal,
DiscoveredDate = new DateTime(2026, 5, 9),
EstimatedReworkCost = 65m
EstimatedReworkCost = 65m,
CreateReworkJob = true,
ReworkJobItemIds = [10],
ReworkPricingType = ReworkPricingType.CustomerFull
});
Assert.IsType<JsonResult>(result);
var reworkJob = await context.Jobs.SingleAsync(j => j.IsReworkJob);
Assert.Equal(1, reworkJob.OriginalJobId);
Assert.Equal("JOB-2605-0001-R1", reworkJob.JobNumber);
Assert.Equal(2, reworkJob.JobStatusId); // first non-Pending status
var reworkItem = await context.JobItems.SingleAsync(i => i.JobId == reworkJob.Id);
Assert.True(reworkItem.IsSalesItem);
@@ -284,13 +289,9 @@ public class QuoteAndReworkControllerFlowTests
CompanyName = "Acme Fabrication"
});
context.JobStatusLookups.Add(new JobStatusLookup
{
Id = 1,
CompanyId = 1,
StatusCode = "PENDING",
DisplayName = "Pending"
});
context.JobStatusLookups.AddRange(
new JobStatusLookup { Id = 1, CompanyId = 1, StatusCode = "PENDING", DisplayName = "Pending", DisplayOrder = 1 },
new JobStatusLookup { Id = 2, CompanyId = 1, StatusCode = "IN_PREPARATION", DisplayName = "In Preparation", DisplayOrder = 2 });
context.JobPriorityLookups.Add(new JobPriorityLookup
{