diff --git a/src/JobsMedical.Web/Pages/Admin/Index.cshtml b/src/JobsMedical.Web/Pages/Admin/Index.cshtml
index 6e454e2..fb804cf 100644
--- a/src/JobsMedical.Web/Pages/Admin/Index.cshtml
+++ b/src/JobsMedical.Web/Pages/Admin/Index.cshtml
@@ -87,6 +87,15 @@
🩺 اصلاح نقشِ آگهیهای «پزشک عمومی» (دندانپزشک/متخصص و …)
+
+
+
+ نقشهای هممعنا (تکراری، ترکیبی مثل «پرستار و بهیار»، یا غلطاملایی مثل «بیهیار») در یک نقشِ پایه ادغام میشوند تا فهرستِ نقشها تمیز شود. مدیریتِ دستی در نقشها.
+
آگهیهایی که هوش مصنوعی به اشتباه «پزشک عمومی» زده ولی متنشان نقش دیگری دارد، از روی متن اصلاح میشوند (درجا، بدون تغییر شناسه/آدرس).
diff --git a/src/JobsMedical.Web/Pages/Admin/Index.cshtml.cs b/src/JobsMedical.Web/Pages/Admin/Index.cshtml.cs
index 19db658..a87bf75 100644
--- a/src/JobsMedical.Web/Pages/Admin/Index.cshtml.cs
+++ b/src/JobsMedical.Web/Pages/Admin/Index.cshtml.cs
@@ -173,6 +173,15 @@ public class IndexModel : PageModel
return RedirectToPage();
}
+ /// Auto-merge duplicate/compound/typo roles minted by the dynamic taxonomy
+ /// («پرستار کودک» ×3، «پرستار و بهیار»، «بیهیار»→بهیار), repointing all listings first.
+ public async Task OnPostMergeRolesAsync()
+ {
+ var n = await _ingest.MergeDuplicateRolesAsync();
+ IngestMessage = $"پاکسازی نقشها: {n} نقشِ تکراری/ترکیبی/غلطاملایی در نقشهای اصلی ادغام شد (آگهیهایشان منتقل شد). فهرست نقشها اکنون تمیزتر است.";
+ return RedirectToPage();
+ }
+
private async Task LoadAsync(int q = 1, int f = 1)
{
QueueTotal = await _db.RawListings.CountAsync(r => r.Status == RawListingStatus.New);
diff --git a/src/JobsMedical.Web/Services/Scraping/IngestionService.cs b/src/JobsMedical.Web/Services/Scraping/IngestionService.cs
index 77543a0..f936925 100644
--- a/src/JobsMedical.Web/Services/Scraping/IngestionService.cs
+++ b/src/JobsMedical.Web/Services/Scraping/IngestionService.cs
@@ -580,6 +580,46 @@ public class IngestionService
return fixedCount;
}
+ ///
+ /// Collapse the role taxonomy that the dynamic ingestion let sprawl: exact duplicates («پرستار
+ /// کودک» ×3), multi-role compounds («پرستار و بهیار»، «ماما / پرستار»), and typos («بیهیار»→بهیار).
+ /// Each role is mapped to a canonical form (strip modifiers → collapse compound to first base role →
+ /// alias) and same-canonical roles merge into one keeper, repointing every shift/job/talent/
+ /// preference/alert/profile first (mirrors the manual /Admin/Roles merge). Returns roles removed.
+ ///
+ public async Task MergeDuplicateRolesAsync(CancellationToken ct = default)
+ {
+ var roles = await _db.Roles.ToListAsync(ct);
+
+ string Canon(string rawName)
+ {
+ var name = StripRoleModifiers(rawName);
+ if (CollapseCompound(roles, name) is { } b) name = b;
+ var norm = NormalizeFa(name);
+ return RoleAliases.TryGetValue(norm, out var c) ? NormalizeFa(c) : norm;
+ }
+
+ int merged = 0;
+ foreach (var g in roles.GroupBy(r => Canon(r.Name)).Where(g => g.Count() > 1))
+ {
+ // Keeper: a role whose own name IS the canonical (a clean base role), then the lowest Id.
+ var keeper = g.OrderBy(r => NormalizeFa(r.Name) == g.Key ? 0 : 1).ThenBy(r => r.Id).First();
+ foreach (var dup in g.Where(r => r.Id != keeper.Id))
+ {
+ await _db.Shifts.Where(x => x.RoleId == dup.Id).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, keeper.Id), ct);
+ await _db.JobOpenings.Where(x => x.RoleId == dup.Id).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, keeper.Id), ct);
+ await _db.TalentListings.Where(x => x.RoleId == dup.Id).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, keeper.Id), ct);
+ await _db.UserPreferences.Where(x => x.RoleId == dup.Id).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, (int?)keeper.Id), ct);
+ await _db.JobAlerts.Where(x => x.RoleId == dup.Id).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, (int?)keeper.Id), ct);
+ await _db.DoctorProfiles.Where(x => x.RoleId == dup.Id).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, (int?)keeper.Id), ct);
+ await _db.Roles.Where(r => r.Id == dup.Id).ExecuteDeleteAsync(ct);
+ merged++;
+ }
+ }
+ _log.LogInformation("Merged {N} duplicate/compound/typo roles.", merged);
+ return merged;
+ }
+
private static string DigitsOnly(string s) => new(HtmlUtil.ToLatinDigits(s).Where(char.IsDigit).ToArray());
private static (RawListingStatus status, string? reason, int confidence) Decide(
@@ -822,12 +862,34 @@ public class IngestionService
/// canonical one instead of forking: (1) exact normalized name, (2) synonym/abbreviation alias
/// → canonical (دکتر→پزشک عمومی، نرس→پرستار…), (3) create. Only TRUE synonyms collapse — real
/// sub-specialties («پرستار ICU») stay distinct on purpose.
+ // Separators that join SEVERAL roles in one ad («پرستار و بهیار»، «ماما / پرستار»، «پزشک و پرستار
+ // و بهیار»). A specialty name that legitimately contains «و» (قلب و عروق، پوست و مو) is NOT split,
+ // because its first segment isn't itself a known role.
+ private static readonly Regex RoleSeparators =
+ new(@"\s*/\s*|\s*،\s*|\s*,\s*|\s+یا\s+|\s+و\s+|\s*\+\s*", RegexOptions.Compiled);
+
+ /// If is a multi-role compound whose FIRST segment is (or aliases
+ /// to) an existing role, return that base role's name; otherwise null. So «پرستار و بهیار» → «پرستار»
+ /// but «قلب و عروق» / «پوست و مو» are left whole.
+ private static string? CollapseCompound(List roles, string name)
+ {
+ var segs = RoleSeparators.Split(name).Select(s => s.Trim()).Where(s => s.Length > 1).ToList();
+ if (segs.Count < 2) return null;
+ var fnorm = NormalizeFa(segs[0]);
+ if (roles.Any(r => NormalizeFa(r.Name) == fnorm)) return segs[0];
+ if (RoleAliases.TryGetValue(fnorm, out var canon) && roles.Any(r => NormalizeFa(r.Name) == NormalizeFa(canon)))
+ return canon;
+ return null;
+ }
+
private Role ResolveOrCreateRole(List roles, string name, string? category)
{
// Drop gender/seniority modifiers baked into the role («پرستار آقا»→«پرستار»,
// «کارآموز تکنسین داروخانه»→«تکنسین داروخانه»). None of the real roles contain these tokens,
// so it only collapses sprawl — the modifier still lives on as a tag / the Gender field.
name = StripRoleModifiers(name);
+ // Collapse a multi-role compound to its first base role so we don't mint «پرستار و بهیار».
+ if (CollapseCompound(roles, name) is { } baseName) name = baseName;
var norm = NormalizeFa(name);
// (1) Already a known role (same word or spelling variant).
@@ -879,6 +941,10 @@ public class IngestionService
["کارشناس آزمایشگاه"] = new[] { "علوم آزمایشگاهی", "تکنسین آزمایشگاه", "آزمایشگاهی", "لابراتوار", "lab", "laboratory" },
["دندانپزشک"] = new[] { "دندان پزشک", "دندون پزشک", "dentist" },
["کمک بهیار"] = new[] { "کمکیار", "کمکیار", "کمک یار", "کمکبهیار", "کمک بیمار" },
+ ["بهیار"] = new[] { "بیهیار", "بیار", "بیهی", "بهییار", "بهیار پرستار" },
+ ["پرستار کودک"] = new[] { "پرستار بچه", "مراقب کودک", "پرستار مراقب کودک", "کودکیار", "مادر یار کودک", "پرستار اطفال" },
+ ["فیزیوتراپیست"] = new[] { "فیزیوتراپ", "فیزیوتراپی" },
+ ["تکنسین داروخانه"] = new[] { "نسخه پیچ", "تکنسین نسخه پیچ" },
});
// Synonyms → canonical CATEGORY (the role-group used for filters/chips).