case 4653 - deleting a record still in use is refused with "in use by" instead of a server error

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 14:57:42 -07:00
parent 6d3b169f23
commit e8b3486a9e
16 changed files with 137 additions and 9 deletions

View File

@@ -285,6 +285,7 @@ Found during the port, out of scope. One line each.
- From case 4755 (tidy-up, no behaviour change): `SetNextBackup` no longer reads `NextBackup`'s previous value, so the startup line `NextBackup = FileUtil.MostRecentAutomatedBackupFileDate()` (`util/ServerGlobalOpsSettingsCache.cs:50`), the reset to `DateTime.MinValue` in `GlobalOpsBackupSettingsBiz` and `FileUtil.MostRecentAutomatedBackupFileDate` itself are dead. Same in Sockeye
- Found in case 4755's integration run (2026-10-31): a 500 response produced by the exception handler (e.g. `/` on a debug server whose `wwwroot` has no client) has none of the three safe headers from case 4727 (F35), because the handler clears headers set before it. Integration test `ApiRoute.SafeHeadersOnEveryResponse` fails for that reason on a debug server without the client in `wwwroot`; it passes on a release build
- Pre-existing (found in the 4632 IIS test, John: Deferred): emailed report attachments have the content type `application/pdf/pdf`, because `Mailer.cs:51` builds `new MimePart("application/pdf", "pdf")` (media type and subtype) instead of `new MimePart("application", "pdf")`. Mail programs still open the file by its `.pdf` name
- Found in case 4653 (pre-existing): `CustomerBiz.ValidateCanDeleteAsync` refuses to delete a customer that has contacts (users), yet `DeleteAsync` then has code to delete the customer's contacts, which can never run. Its comment says contacts were meant to be deleted with the customer, and that the check is there "for now". Decide which behaviour is wanted; same in Sockeye
## Log

View File

@@ -18,6 +18,7 @@ IN PROGRESS
- Server: in some cases the automatic backup could be skipped for one day after the AyaNova server was restarted or a manual backup was made; it now always runs at the next chosen [backup time](ops-form-backup.md#backup-time).
- Server: opening a record that has since been deleted (for example from an old link) now shows a "not found" message instead of a server error, and a report printed for records of which some have since been deleted now prints the remaining ones instead of failing.
- Server: in some cases deleting a record that is still in use elsewhere (for example a customer, unit, part or rate used on a work order, quote or PM) gave a server error; it now always says which kind of record is using it.
### AyaNova 8.3.0

View File

@@ -339,6 +339,12 @@ namespace AyaNova.Biz
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("CustomerServiceRequest"));
if (await ct.PurchaseOrder.AnyAsync(m => m.DropShipToCustomerId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("PurchaseOrder"));
if (await ct.WorkOrder.AnyAsync(z => z.CustomerId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("WorkOrder"));
if (await ct.Quote.AnyAsync(z => z.CustomerId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("Quote"));
if (await ct.PM.AnyAsync(z => z.CustomerId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("PM"));
// await Task.CompletedTask;
}

View File

@@ -120,7 +120,7 @@ namespace AyaNova.Biz
AddError(ApiErrorCode.NOT_FOUND);
return false;
}
ValidateCanDelete(dbObject);
await ValidateCanDeleteAsync(dbObject);
if (HasErrors)
return false;
{
@@ -239,12 +239,12 @@ namespace AyaNova.Biz
}
private void ValidateCanDelete(CustomerServiceRequest inObj)
private async Task ValidateCanDeleteAsync(CustomerServiceRequest inObj)
{
//whatever needs to be check to delete this object
//can't delete if it's accepted
//FOREIGN KEY CHECKS
//an accepted request is linked from the work order item made from it
if (await ct.WorkOrderItem.AnyAsync(z => z.FromCSRId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("WorkOrder"));
}

View File

@@ -125,7 +125,7 @@ namespace AyaNova.Biz
AddError(ApiErrorCode.NOT_FOUND);
return false;
}
ValidateCanDelete(dbObject);
await ValidateCanDeleteAsync(dbObject);
if (HasErrors)
return false;
{
@@ -230,9 +230,15 @@ namespace AyaNova.Biz
}
private void ValidateCanDelete(LoanUnit inObj)
private async Task ValidateCanDeleteAsync(LoanUnit inObj)
{
//whatever needs to be check to delete this object
//FOREIGN KEY CHECKS
if (await ct.WorkOrderItemLoan.AnyAsync(z => z.LoanUnitId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("WorkOrderItemLoan"));
if (await ct.QuoteItemLoan.AnyAsync(z => z.LoanUnitId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("Quote") + " - " + await Translate("WorkOrderItemLoan"));
if (await ct.PMItemLoan.AnyAsync(z => z.LoanUnitId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("PM") + " - " + await Translate("WorkOrderItemLoan"));
}

View File

@@ -467,6 +467,16 @@ namespace AyaNova.Biz
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("PartStockingLevels"));
if (await ct.PurchaseOrderItem.AnyAsync(m => m.PartId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("PurchaseOrder"));
var quotetext = await Translate("Quote");
var pmtext = await Translate("PM");
if (await ct.WorkOrderItemPart.AnyAsync(z => z.PartId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("WorkOrderItemPart"));
if (await ct.QuoteItemPart.AnyAsync(z => z.PartId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", quotetext + " - " + await Translate("WorkOrderItemPart"));
if (await ct.PMItemPart.AnyAsync(z => z.PartId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", pmtext + " - " + await Translate("WorkOrderItemPart"));
if (await ct.WorkOrderItemPartRequest.AnyAsync(z => z.PartId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("WorkOrderItemPartRequest"));
}

View File

@@ -246,6 +246,16 @@ namespace AyaNova.Biz
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("PartStockingLevels"));
if (await ct.PurchaseOrderItem.AnyAsync(m => m.PartWarehouseId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("PurchaseOrder"));
var quotetext = await Translate("Quote");
var pmtext = await Translate("PM");
if (await ct.WorkOrderItemPart.AnyAsync(z => z.PartWarehouseId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("WorkOrderItemPart"));
if (await ct.QuoteItemPart.AnyAsync(z => z.PartWarehouseId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", quotetext + " - " + await Translate("WorkOrderItemPart"));
if (await ct.PMItemPart.AnyAsync(z => z.PartWarehouseId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", pmtext + " - " + await Translate("WorkOrderItemPart"));
if (await ct.WorkOrderItemPartRequest.AnyAsync(z => z.PartWarehouseId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("WorkOrderItemPartRequest"));
}

View File

@@ -236,6 +236,12 @@ namespace AyaNova.Biz
//FOREIGN KEY CHECKS
if (await ct.PurchaseOrder.AnyAsync(m => m.ProjectId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("PurchaseOrder"));
if (await ct.WorkOrder.AnyAsync(z => z.ProjectId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("WorkOrder"));
if (await ct.Quote.AnyAsync(z => z.ProjectId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("Quote"));
if (await ct.PM.AnyAsync(z => z.ProjectId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("PM"));
}

View File

@@ -273,6 +273,16 @@ namespace AyaNova.Biz
if (await ct.WorkOrderItemScheduledUser.AnyAsync(x => x.ServiceRateId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("WorkOrderItemScheduledUser"));
var quotetext = await Translate("Quote");
var pmtext = await Translate("PM");
if (await ct.QuoteItemLabor.AnyAsync(z => z.ServiceRateId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", quotetext + " - " + await Translate("WorkOrderItemLabor"));
if (await ct.PMItemLabor.AnyAsync(z => z.ServiceRateId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", pmtext + " - " + await Translate("WorkOrderItemLabor"));
if (await ct.QuoteItemScheduledUser.AnyAsync(z => z.ServiceRateId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", quotetext + " - " + await Translate("WorkOrderItemScheduledUser"));
if (await ct.PMItemScheduledUser.AnyAsync(z => z.ServiceRateId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", pmtext + " - " + await Translate("WorkOrderItemScheduledUser"));
}

View File

@@ -294,6 +294,34 @@ namespace AyaNova.Biz
if (await ct.WorkOrderItemExpense.AnyAsync(x => x.ChargeTaxCodeId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("WorkOrderItemExpense"));
if (await ct.WorkOrderItemOutsideService.AnyAsync(z => z.TaxCodeId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("WorkOrderItemOutsideService"));
var quotetext = await Translate("Quote");
var pmtext = await Translate("PM");
if (await ct.QuoteItemTravel.AnyAsync(z => z.TaxCodeSaleId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", quotetext + " - " + await Translate("WorkOrderItemTravel"));
if (await ct.PMItemTravel.AnyAsync(z => z.TaxCodeSaleId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", pmtext + " - " + await Translate("WorkOrderItemTravel"));
if (await ct.QuoteItemPart.AnyAsync(z => z.TaxPartSaleId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", quotetext + " - " + await Translate("WorkOrderItemPart"));
if (await ct.PMItemPart.AnyAsync(z => z.TaxPartSaleId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", pmtext + " - " + await Translate("WorkOrderItemPart"));
if (await ct.QuoteItemLoan.AnyAsync(z => z.TaxCodeId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", quotetext + " - " + await Translate("WorkOrderItemLoan"));
if (await ct.PMItemLoan.AnyAsync(z => z.TaxCodeId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", pmtext + " - " + await Translate("WorkOrderItemLoan"));
if (await ct.QuoteItemLabor.AnyAsync(z => z.TaxCodeSaleId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", quotetext + " - " + await Translate("WorkOrderItemLabor"));
if (await ct.PMItemLabor.AnyAsync(z => z.TaxCodeSaleId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", pmtext + " - " + await Translate("WorkOrderItemLabor"));
if (await ct.QuoteItemExpense.AnyAsync(z => z.ChargeTaxCodeId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", quotetext + " - " + await Translate("WorkOrderItemExpense"));
if (await ct.PMItemExpense.AnyAsync(z => z.ChargeTaxCodeId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", pmtext + " - " + await Translate("WorkOrderItemExpense"));
if (await ct.QuoteItemOutsideService.AnyAsync(z => z.TaxCodeId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", quotetext + " - " + await Translate("WorkOrderItemOutsideService"));
if (await ct.PMItemOutsideService.AnyAsync(z => z.TaxCodeId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", pmtext + " - " + await Translate("WorkOrderItemOutsideService"));
}

View File

@@ -579,6 +579,12 @@ namespace AyaNova.Biz
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, null, "Can't be deleted in use by one or more Users");
return;
}
if (await ct.CustomerNotifySubscription.AnyAsync(e => e.TranslationId == inObj.Id))
{
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, null, "Can't be deleted in use by one or more customer notification subscriptions");
return;
}
}

View File

@@ -268,6 +268,14 @@ namespace AyaNova.Biz
//FOREIGN KEY CHECKS
if (await ct.ContractTravelRate.AnyAsync(m => m.TravelRateId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("Contract"));
var quotetext = await Translate("Quote");
var pmtext = await Translate("PM");
if (await ct.WorkOrderItemTravel.AnyAsync(z => z.TravelRateId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("WorkOrderItemTravel"));
if (await ct.QuoteItemTravel.AnyAsync(z => z.TravelRateId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", quotetext + " - " + await Translate("WorkOrderItemTravel"));
if (await ct.PMItemTravel.AnyAsync(z => z.TravelRateId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", pmtext + " - " + await Translate("WorkOrderItemTravel"));
}

View File

@@ -286,6 +286,20 @@ namespace AyaNova.Biz
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("LoanUnit"));
if (await ct.CustomerServiceRequest.AnyAsync(z => z.UnitId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("CustomerServiceRequest"));
var quotetext = await Translate("Quote");
var pmtext = await Translate("PM");
if (await ct.WorkOrderItemUnit.AnyAsync(z => z.UnitId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("WorkOrderItemUnit"));
if (await ct.QuoteItemUnit.AnyAsync(z => z.UnitId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", quotetext + " - " + await Translate("WorkOrderItemUnit"));
if (await ct.PMItemUnit.AnyAsync(z => z.UnitId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", pmtext + " - " + await Translate("WorkOrderItemUnit"));
if (await ct.WorkOrderItemOutsideService.AnyAsync(z => z.UnitId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("WorkOrderItemOutsideService"));
if (await ct.QuoteItemOutsideService.AnyAsync(z => z.UnitId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", quotetext + " - " + await Translate("WorkOrderItemOutsideService"));
if (await ct.PMItemOutsideService.AnyAsync(z => z.UnitId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", pmtext + " - " + await Translate("WorkOrderItemOutsideService"));
}

View File

@@ -284,6 +284,20 @@ namespace AyaNova.Biz
if (await ct.PurchaseOrder.AnyAsync(m => m.VendorId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("PurchaseOrder"));
var quotetext = await Translate("Quote");
var pmtext = await Translate("PM");
if (await ct.WorkOrderItemOutsideService.AnyAsync(z => z.VendorSentToId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("WorkOrderItemOutsideService"));
if (await ct.QuoteItemOutsideService.AnyAsync(z => z.VendorSentToId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", quotetext + " - " + await Translate("WorkOrderItemOutsideService"));
if (await ct.PMItemOutsideService.AnyAsync(z => z.VendorSentToId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", pmtext + " - " + await Translate("WorkOrderItemOutsideService"));
if (await ct.WorkOrderItemOutsideService.AnyAsync(z => z.VendorSentViaId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("WorkOrderItemOutsideService"));
if (await ct.QuoteItemOutsideService.AnyAsync(z => z.VendorSentViaId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", quotetext + " - " + await Translate("WorkOrderItemOutsideService"));
if (await ct.PMItemOutsideService.AnyAsync(z => z.VendorSentViaId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", pmtext + " - " + await Translate("WorkOrderItemOutsideService"));
}

View File

@@ -208,6 +208,10 @@ namespace AyaNova.Biz
//FOREIGN KEY CHECKS
if (await ct.WorkOrderItem.AnyAsync(m => m.WorkOrderItemPriorityId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("WorkOrderItem"));
if (await ct.QuoteItem.AnyAsync(z => z.WorkOrderItemPriorityId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("Quote"));
if (await ct.PMItem.AnyAsync(z => z.WorkOrderItemPriorityId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("PM"));
}

View File

@@ -210,6 +210,10 @@ namespace AyaNova.Biz
//FOREIGN KEY CHECKS
if (await ct.WorkOrderItem.AnyAsync(m => m.WorkOrderItemStatusId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("WorkOrderItem"));
if (await ct.QuoteItem.AnyAsync(z => z.WorkOrderItemStatusId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("Quote"));
if (await ct.PMItem.AnyAsync(z => z.WorkOrderItemStatusId == inObj.Id))
AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("PM"));
}