Add «آماده به کار» (talent) listing type — workers offering themselves
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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user