Applicants: auto-tags + deep search w/ highlight; never delete (archive instead)
CI/CD / CI · dotnet build (push) Successful in 2m1s
CI/CD / Deploy · hamkadr (push) Successful in 2m36s

- Tags: parser extracts cert/skill keywords (mmt, ICU/CCU, دیالیز, اتاق عمل,
  اورژانس, مسئول فنی, پروانه‌دار…) + role + city into TalentListing.Tags
  (+ migration); shown as chips on cards.
- Deep search on /Talent: «جستجوی عمیق» box does Postgres ILIKE across
  tags, description, person, area, role, city (every term must match);
  matches are highlighted with <mark> via SearchHighlight.
- Never delete: ShiftStatus.Archived + the admin «بایگانی گروهی» action now
  ARCHIVES aggregated posts (hidden from site, kept in DB) and leaves the
  raw crawl rows intact — a permanent archive for future analytics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-08 11:25:32 +03:30
parent e4dc5180ad
commit 6b657c7795
15 changed files with 1752 additions and 22 deletions
@@ -24,13 +24,13 @@
@{ int publishedCount = Model.Counts.GetValueOrDefault(JobsMedical.Web.Models.RawListingStatus.Normalized); }
@if (publishedCount > 0)
{
<form method="post" asp-page-handler="DeletePublished"
onsubmit="return confirm('همه آگهی‌های منتشرشده از جمع‌آوری (شیفت/استخدام/آماده‌به‌کار) و آیتم‌های تأییدشده‌ی متناظر حذف می‌شوند. این کار بازگشت‌ناپذیر است. ادامه می‌دهی؟');"
<form method="post" asp-page-handler="ArchivePublished"
onsubmit="return confirm('همه آگهی‌های منتشرشده از جمع‌آوری از سایت پنهان (بایگانی) می‌شوند. داده‌ها حذف نمی‌شوند و برای تحلیل باقی می‌مانند. ادامه می‌دهی؟');"
style="margin-bottom:14px;">
<button type="submit" class="btn btn-outline" style="color:var(--danger); border-color:var(--danger);">
🗑 حذف گروهی همه‌ی منتشرشده‌ها (@JalaliDate.ToPersianDigits(publishedCount.ToString()))
<button type="submit" class="btn btn-outline">
🗄 بایگانی گروهی همه‌ی منتشرشده‌ها (@JalaliDate.ToPersianDigits(publishedCount.ToString()))
</button>
<span class="muted" style="font-size:12px; margin-inline-start:8px;">آگهی‌های منتشرشده روی سایت را که از جمع‌آوری ساخته شده‌اند یکجا حذف می‌کند.</span>
<span class="muted" style="font-size:12px; margin-inline-start:8px;">از سایت پنهان می‌کند ولی هیچ‌چیز حذف نمی‌شود (آرشیو برای تحلیل).</span>
</form>
}
@@ -47,21 +47,24 @@ public class IngestedModel : PageModel
}
/// <summary>
/// Bulk-delete everything that was published from ingestion: the aggregated Shift/Job/Talent
/// posts on the site AND the approved (Normalized) raw items that produced them. Done in a
/// transaction; the linked raw rows are removed first since they hold FKs to the posts.
/// ARCHIVE (never delete) everything published from ingestion: the aggregated Shift/Job/Talent
/// posts are flipped to Archived (hidden from the site but kept for analytics); the raw crawl
/// rows are retained untouched as the permanent archive.
/// </summary>
public async Task<IActionResult> OnPostDeletePublishedAsync()
public async Task<IActionResult> OnPostArchivePublishedAsync()
{
await using var tx = await _db.Database.BeginTransactionAsync();
var raws = await _db.RawListings.Where(r => r.Status == RawListingStatus.Normalized).ExecuteDeleteAsync();
var shifts = await _db.Shifts.Where(s => s.Source == ShiftSource.Aggregated).ExecuteDeleteAsync();
var jobs = await _db.JobOpenings.Where(j => j.Source == ShiftSource.Aggregated).ExecuteDeleteAsync();
var talent = await _db.TalentListings.Where(t => t.Source == ShiftSource.Aggregated).ExecuteDeleteAsync();
await tx.CommitAsync();
var shifts = await _db.Shifts
.Where(s => s.Source == ShiftSource.Aggregated && s.Status != ShiftStatus.Archived)
.ExecuteUpdateAsync(u => u.SetProperty(s => s.Status, ShiftStatus.Archived));
var jobs = await _db.JobOpenings
.Where(j => j.Source == ShiftSource.Aggregated && j.Status != ShiftStatus.Archived)
.ExecuteUpdateAsync(u => u.SetProperty(j => j.Status, ShiftStatus.Archived));
var talent = await _db.TalentListings
.Where(t => t.Source == ShiftSource.Aggregated && t.Status != ShiftStatus.Archived)
.ExecuteUpdateAsync(u => u.SetProperty(t => t.Status, ShiftStatus.Archived));
string P(int n) => JalaliDate.ToPersianDigits(n.ToString());
Message = $"حذف شد: {P(shifts)} شیفت، {P(jobs)} استخدام، {P(talent)} آماده‌به‌کار و {P(raws)} آیتم جمع‌آوری.";
Message = $"بایگانی شد (از سایت پنهان، در پایگاه‌داده نگه‌داری شد): {P(shifts)} شیفت، {P(jobs)} استخدام، {P(talent)} آماده‌به‌کار.";
return RedirectToPage(new { Status });
}
}
@@ -134,11 +134,11 @@ public class ReviewModel : PageModel
Error = "شهری برای انتشار آگهی «آماده به کار» موجود نیست.";
return RedirectToPage(new { id });
}
// Re-parse the raw text to recover all contact channels (phones/email/socials).
// Re-parse the raw text to recover all contact channels (phones/email/socials) + tags.
var roleNames = await _db.Roles.Select(r => r.Name).ToListAsync();
var parsedContacts = _parser
.Parse(Raw.RawText, roleNames, await CityNamesAsync(), await DistrictNamesAsync())
.Contacts.Select((c, i) => new ContactMethod { Type = c.Type, Value = c.Value, SortOrder = i })
var reparsed = _parser.Parse(Raw.RawText, roleNames, await CityNamesAsync(), await DistrictNamesAsync());
var parsedContacts = reparsed.Contacts
.Select((c, i) => new ContactMethod { Type = c.Type, Value = c.Value, SortOrder = i })
.ToList();
// Include the admin-typed phone if it isn't already captured.
if (!string.IsNullOrWhiteSpace(Phone))
@@ -167,6 +167,7 @@ public class ReviewModel : PageModel
Source = ShiftSource.Aggregated,
SourceUrl = Raw.SourceUrl,
Contacts = parsedContacts,
Tags = string.Join(" ", reparsed.Tags.Distinct()),
};
_db.TalentListings.Add(talent);
await _db.SaveChangesAsync();
@@ -12,10 +12,13 @@
? (Model.Role?.Name ?? "آماده به کار")
: Model.PersonName!;
var area = Model.District?.Name ?? Model.AreaNote;
var q = ViewData["q"] as string;
var tags = (Model.Tags ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Where(t => t != Model.Role?.Name && t != Model.City?.Name).Distinct().Take(6).ToList();
}
<a class="card card-pad shift-card" asp-page="/Talent/Details" asp-route-id="@Model.Id">
<div class="row" style="justify-content: space-between;">
<span class="facility">@heading</span>
<span class="facility">@JobsMedical.Web.Services.SearchHighlight.Mark(heading, q)</span>
<span class="badge badge-talent">آماده به کار</span>
</div>
<div class="row">
@@ -37,6 +40,15 @@
}
</div>
<div class="row">📍 @Model.City?.Name@(area is not null ? "، " + area : "")</div>
@if (tags.Count > 0)
{
<div class="tag-chips">
@foreach (var tg in tags)
{
<span class="tag-chip">@JobsMedical.Web.Services.SearchHighlight.Mark(tg, q)</span>
}
</div>
}
<div class="foot">
<span class="pay">@comp</span>
<span class="btn btn-outline" style="padding: 6px 14px;">مشاهده و تماس</span>
@@ -3,6 +3,7 @@
@{
ViewData["Title"] = "آماده به کار — کادر درمان";
ViewData["Description"] = "فهرست کادر درمان آماده همکاری (پزشک، پرستار، ماما، تکنسین و…) در تهران و سایر شهرها — مرکز درمانی می‌تواند مستقیم تماس بگیرد.";
ViewData["q"] = Model.Q; // drives result highlighting in cards
}
<div class="page-head">
@@ -20,6 +21,14 @@
<aside class="card card-pad filter-card">
<h3>فیلترها</h3>
<form method="get" id="filterForm">
<div class="filter-group">
<label>جستجوی عمیق</label>
<div style="display:flex; gap:6px;">
<input type="search" name="Q" value="@Model.Q" placeholder="مثلاً mmt پروانه‌دار تهران" style="flex:1;" />
<button type="submit" class="btn btn-accent" style="padding:0 14px;">🔎</button>
</div>
<p class="muted" style="font-size:11px; margin:4px 0 0;">روی متن، تگ‌ها، نقش، شهر و نام جستجو می‌کند.</p>
</div>
<div class="filter-group">
<label>شهر</label>
<select name="CityId" onchange="this.form.submit()">
@@ -16,6 +16,7 @@ public class IndexModel : PageModel
[BindProperty(SupportsGet = true)] public int? DistrictId { get; set; }
[BindProperty(SupportsGet = true)] public int? RoleId { get; set; }
[BindProperty(SupportsGet = true)] public Gender? GenderFilter { get; set; }
[BindProperty(SupportsGet = true)] public string? Q { get; set; } // deep search
public List<TalentListing> Results { get; private set; } = new();
public List<City> Cities { get; private set; } = new();
@@ -42,6 +43,20 @@ public class IndexModel : PageModel
if (GenderFilter is Gender g && g != Gender.Any)
q = q.Where(t => t.Gender == Gender.Any || t.Gender == g);
// Deep search: every term must match somewhere (tags, role, city, person, area, description).
if (!string.IsNullOrWhiteSpace(Q))
foreach (var term in Q.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var like = $"%{term}%";
q = q.Where(t =>
EF.Functions.ILike(t.Tags ?? "", like) ||
EF.Functions.ILike(t.Description ?? "", like) ||
EF.Functions.ILike(t.PersonName ?? "", like) ||
EF.Functions.ILike(t.AreaNote ?? "", like) ||
EF.Functions.ILike(t.Role.Name, like) ||
EF.Functions.ILike(t.City.Name, like));
}
Results = await q.OrderByDescending(t => t.CreatedAt).ToListAsync();
}
}