Add «آماده به کار» (talent) listing type — workers offering themselves
CI/CD / CI · dotnet build (push) Successful in 1m41s
CI/CD / Deploy · hamkadr (push) Has been cancelled

Adds a third listing kind alongside Shift/Job for healthcare staff who
advertise their own availability (very common in Iranian medical
channels, e.g. "دندانپزشک آماده همکاری… ۵۰٪ تسویه"). These have no
facility; the contact phone is the key field.

- Model: TalentListing (role, person name, years, licensed, city/district,
  area note, availability, gender, comp, phone) + ListingKind.Talent +
  RawListing.LinkedTalentId + DbSet/relations/indexes + EF migration.
- Parser: detect آماده‌به‌کار/جویای کار → Kind=Talent; extract person name,
  years of experience, licensed flag, area («منطقه ۱»), phone. Facility
  name extraction now skipped for talent.
- Validator: talent path scores role + phone + medical (no facility/pay
  required).
- Ingestion auto-publish: creates a TalentListing for talent kind.
- Review (manual publish): Talent option + talent fields; publishes a
  TalentListing without a facility. Shift/Job facility now falls back to a
  shared «نامشخص / ثبت نشده» record when the ad names none — publishing
  never fails on a missing facility.
- Browse /Talent (indexable, filters: city/district/role/gender),
  details /Talent/Details (noindex — personal contact, tel: call button),
  _TalentCard, badge-talent, nav link, home section.
- Sitemap includes /Talent; robots disallows /Talent/Details. Archiver
  expires stale talent listings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-08 08:01:12 +03:30
parent bdcca5e548
commit 4e5df73cf7
24 changed files with 2327 additions and 34 deletions
@@ -27,6 +27,7 @@ public class ReviewModel : PageModel
public ParsedListing? Parsed { get; private set; }
public List<Facility> Facilities { get; private set; } = new();
public List<Role> Roles { get; private set; } = new();
public List<City> Cities { get; private set; } = new();
[TempData] public string? Error { get; set; }
@@ -50,6 +51,13 @@ public class ReviewModel : PageModel
[BindProperty] public EmploymentType EmploymentType { get; set; }
[BindProperty] public long? SalaryMin { get; set; }
[BindProperty] public long? SalaryMax { get; set; }
// Talent («آماده به کار») fields — no facility; contact phone is key.
[BindProperty] public int TalentCityId { get; set; }
[BindProperty] public string? PersonName { get; set; }
[BindProperty] public int? YearsExperience { get; set; }
[BindProperty] public bool IsLicensed { get; set; }
[BindProperty] public string? AreaNote { get; set; }
[BindProperty] public string? Phone { get; set; }
public async Task<IActionResult> OnGetAsync(int id)
{
@@ -74,6 +82,15 @@ public class ReviewModel : PageModel
Description = Raw.RawText;
Title = Parsed.RoleName is not null ? $"استخدام {Parsed.RoleName}" : "موقعیت استخدامی";
// Talent prefill.
Phone = Parsed.Phone;
PersonName = Parsed.PersonName;
YearsExperience = Parsed.YearsExperience;
IsLicensed = Parsed.IsLicensed;
AreaNote = Parsed.AreaNote;
TalentCityId = Cities.FirstOrDefault(c => c.Name == Parsed.CityName)?.Id
?? Cities.FirstOrDefault()?.Id ?? 0;
// Facility: try to match the listing's facility to one we already have; otherwise
// prefill the "new facility" box so publishing creates it.
if (!string.IsNullOrWhiteSpace(Parsed.FacilityName))
@@ -100,21 +117,61 @@ public class ReviewModel : PageModel
Raw = await _db.RawListings.FirstOrDefaultAsync(r => r.Id == id);
if (Raw is null) return NotFound();
// Resolve the facility: prefer the picked one; otherwise create from the typed name.
// This prevents FK_Shifts_Facilities_FacilityId violations when no facility is selected
// (e.g. the dropdown is empty because no facilities exist yet).
var facilityId = await ResolveFacilityIdAsync();
if (facilityId is null)
{
Error = "یک مرکز درمانی معتبر انتخاب کن، یا در کادر «نام مرکز جدید» نام مرکز را وارد کن تا ساخته شود.";
return RedirectToPage(new { id });
}
if (!await _db.Roles.AnyAsync(r => r.Id == RoleId))
{
Error = "یک نقش معتبر انتخاب کن.";
return RedirectToPage(new { id });
}
// «آماده به کار» — a worker offering themselves. No facility; publish a TalentListing.
if (Kind == ListingKind.Talent)
{
var cityId = TalentCityId > 0 && await _db.Cities.AnyAsync(c => c.Id == TalentCityId)
? TalentCityId
: await _db.Cities.OrderByDescending(c => c.IsActive).Select(c => (int?)c.Id).FirstOrDefaultAsync();
if (cityId is null)
{
Error = "شهری برای انتشار آگهی «آماده به کار» موجود نیست.";
return RedirectToPage(new { id });
}
var talent = new TalentListing
{
RoleId = RoleId,
CityId = cityId.Value,
PersonName = string.IsNullOrWhiteSpace(PersonName) ? null : PersonName.Trim(),
YearsExperience = YearsExperience,
IsLicensed = IsLicensed,
AreaNote = string.IsNullOrWhiteSpace(AreaNote) ? null : AreaNote.Trim(),
Availability = EmploymentType,
Gender = GenderRequirement,
PayType = Negotiable ? PayType.Negotiable
: (PayAmount is null && SharePercent is not null ? PayType.Percentage : PayType.PerShift),
PayAmount = Negotiable ? null : PayAmount,
SharePercent = Negotiable ? null : SharePercent,
Phone = string.IsNullOrWhiteSpace(Phone) ? null : Phone.Trim(),
Description = Description,
Status = ShiftStatus.Open,
Source = ShiftSource.Aggregated,
SourceUrl = Raw.SourceUrl,
};
_db.TalentListings.Add(talent);
await _db.SaveChangesAsync();
Raw.Status = RawListingStatus.Normalized;
Raw.LinkedTalentId = talent.Id;
await _db.SaveChangesAsync();
return RedirectToPage("/Admin/Index");
}
// Shift/Job need a facility. Resolve the picked/typed one, falling back to a single
// shared «نامشخص / ثبت نشده» record when the ad doesn't name a facility — so publishing
// never fails on a missing facility.
var facilityId = await ResolveFacilityIdAsync();
if (facilityId is null)
{
Error = "شهری برای ساخت مرکز موجود نیست؛ ابتدا یک شهر اضافه کن.";
return RedirectToPage(new { id });
}
Shift? createdShift = null;
JobOpening? createdJob = null;
if (Kind == ListingKind.Shift)
@@ -188,24 +245,27 @@ public class ReviewModel : PageModel
_ => (new TimeOnly(8, 0), new TimeOnly(8, 0)),
};
/// <summary>Placeholder facility name used when an ad doesn't name a real one.</summary>
private const string UnknownFacilityName = "نامشخص / ثبت نشده";
/// <summary>
/// Returns a valid existing FacilityId, creating a new unverified facility from
/// <see cref="NewFacilityName"/> when nothing valid is selected. Returns null when
/// neither a valid facility is picked nor a name is provided.
/// Returns a valid FacilityId. Prefers the picked facility, then the typed/parsed name
/// (reusing a fuzzy match before creating), and finally falls back to a single shared
/// «نامشخص / ثبت نشده» record so publishing never fails for a missing facility.
/// Returns null only when there are no cities at all.
/// </summary>
private async Task<int?> ResolveFacilityIdAsync()
{
if (FacilityId > 0 && await _db.Facilities.AnyAsync(f => f.Id == FacilityId))
return FacilityId;
if (string.IsNullOrWhiteSpace(NewFacilityName))
return null;
var name = NewFacilityName.Trim();
var cityId = await _db.Cities.OrderByDescending(c => c.IsActive)
.Select(c => (int?)c.Id).FirstOrDefaultAsync();
if (cityId is null) return null; // no cities seeded — cannot create a facility
// No facility named in the ad → use/create the shared placeholder.
var name = string.IsNullOrWhiteSpace(NewFacilityName) ? UnknownFacilityName : NewFacilityName.Trim();
// Reuse an existing facility that's exactly or closely the same (Persian-aware fuzzy
// match), so we don't create duplicates like «بیمارستان میلاد» vs «میلاد».
var all = await _db.Facilities.ToListAsync();
@@ -229,6 +289,7 @@ public class ReviewModel : PageModel
{
Facilities = await _db.Facilities.Include(f => f.City).OrderBy(f => f.Name).ToListAsync();
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
}
private Task<List<string>> CityNamesAsync() => _db.Cities.Select(c => c.Name).ToListAsync();