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
+14
View File
@@ -19,6 +19,7 @@ public class AppDbContext : DbContext, IDataProtectionKeyContext
public DbSet<Facility> Facilities => Set<Facility>(); public DbSet<Facility> Facilities => Set<Facility>();
public DbSet<Shift> Shifts => Set<Shift>(); public DbSet<Shift> Shifts => Set<Shift>();
public DbSet<JobOpening> JobOpenings => Set<JobOpening>(); public DbSet<JobOpening> JobOpenings => Set<JobOpening>();
public DbSet<TalentListing> TalentListings => Set<TalentListing>();
public DbSet<Application> Applications => Set<Application>(); public DbSet<Application> Applications => Set<Application>();
public DbSet<RawListing> RawListings => Set<RawListing>(); public DbSet<RawListing> RawListings => Set<RawListing>();
public DbSet<Visitor> Visitors => Set<Visitor>(); public DbSet<Visitor> Visitors => Set<Visitor>();
@@ -142,6 +143,19 @@ public class AppDbContext : DbContext, IDataProtectionKeyContext
b.Entity<JobOpening>().HasIndex(j => j.Status); b.Entity<JobOpening>().HasIndex(j => j.Status);
b.Entity<JobOpening>().HasIndex(j => j.FacilityId); b.Entity<JobOpening>().HasIndex(j => j.FacilityId);
// Talent listings («آماده به کار») — no facility; keep role/city but don't cascade from them.
b.Entity<TalentListing>()
.HasOne(t => t.Role).WithMany()
.HasForeignKey(t => t.RoleId).OnDelete(DeleteBehavior.Restrict);
b.Entity<TalentListing>()
.HasOne(t => t.City).WithMany()
.HasForeignKey(t => t.CityId).OnDelete(DeleteBehavior.Restrict);
b.Entity<TalentListing>()
.HasOne(t => t.District).WithMany()
.HasForeignKey(t => t.DistrictId).OnDelete(DeleteBehavior.SetNull);
b.Entity<TalentListing>().HasIndex(t => t.Status);
b.Entity<TalentListing>().HasIndex(t => new { t.CityId, t.RoleId });
b.Entity<WebPushSubscription>().HasIndex(s => s.Endpoint).IsUnique(); b.Entity<WebPushSubscription>().HasIndex(s => s.Endpoint).IsUnique();
b.Entity<Notification>() b.Entity<Notification>()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class AddTalentListing : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "LinkedTalentId",
table: "RawListings",
type: "integer",
nullable: true);
migrationBuilder.CreateTable(
name: "TalentListings",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
RoleId = table.Column<int>(type: "integer", nullable: false),
PersonName = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: true),
YearsExperience = table.Column<int>(type: "integer", nullable: true),
IsLicensed = table.Column<bool>(type: "boolean", nullable: false),
CityId = table.Column<int>(type: "integer", nullable: false),
DistrictId = table.Column<int>(type: "integer", nullable: true),
AreaNote = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: true),
Availability = table.Column<int>(type: "integer", nullable: true),
Gender = table.Column<int>(type: "integer", nullable: false),
PayType = table.Column<int>(type: "integer", nullable: false),
PayAmount = table.Column<long>(type: "bigint", nullable: true),
SharePercent = table.Column<int>(type: "integer", nullable: true),
Phone = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
Description = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
Status = table.Column<int>(type: "integer", nullable: false),
Source = table.Column<int>(type: "integer", nullable: false),
SourceUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TalentListings", x => x.Id);
table.ForeignKey(
name: "FK_TalentListings_Cities_CityId",
column: x => x.CityId,
principalTable: "Cities",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TalentListings_Districts_DistrictId",
column: x => x.DistrictId,
principalTable: "Districts",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_TalentListings_Roles_RoleId",
column: x => x.RoleId,
principalTable: "Roles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_TalentListings_CityId_RoleId",
table: "TalentListings",
columns: new[] { "CityId", "RoleId" });
migrationBuilder.CreateIndex(
name: "IX_TalentListings_DistrictId",
table: "TalentListings",
column: "DistrictId");
migrationBuilder.CreateIndex(
name: "IX_TalentListings_RoleId",
table: "TalentListings",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "IX_TalentListings_Status",
table: "TalentListings",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TalentListings");
migrationBuilder.DropColumn(
name: "LinkedTalentId",
table: "RawListings");
}
}
}
@@ -673,6 +673,9 @@ namespace JobsMedical.Web.Migrations
b.Property<int?>("LinkedShiftId") b.Property<int?>("LinkedShiftId")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<int?>("LinkedTalentId")
.HasColumnType("integer");
b.Property<string>("ParsedJson") b.Property<string>("ParsedJson")
.HasColumnType("text"); .HasColumnType("text");
@@ -887,6 +890,86 @@ namespace JobsMedical.Web.Migrations
b.ToTable("Shifts"); b.ToTable("Shifts");
}); });
modelBuilder.Entity("JobsMedical.Web.Models.TalentListing", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AreaNote")
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<int?>("Availability")
.HasColumnType("integer");
b.Property<int>("CityId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<int?>("DistrictId")
.HasColumnType("integer");
b.Property<int>("Gender")
.HasColumnType("integer");
b.Property<bool>("IsLicensed")
.HasColumnType("boolean");
b.Property<long?>("PayAmount")
.HasColumnType("bigint");
b.Property<int>("PayType")
.HasColumnType("integer");
b.Property<string>("PersonName")
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<string>("Phone")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<int>("RoleId")
.HasColumnType("integer");
b.Property<int?>("SharePercent")
.HasColumnType("integer");
b.Property<int>("Source")
.HasColumnType("integer");
b.Property<string>("SourceUrl")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<int?>("YearsExperience")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("DistrictId");
b.HasIndex("RoleId");
b.HasIndex("Status");
b.HasIndex("CityId", "RoleId");
b.ToTable("TalentListings");
});
modelBuilder.Entity("JobsMedical.Web.Models.User", b => modelBuilder.Entity("JobsMedical.Web.Models.User", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -1282,6 +1365,32 @@ namespace JobsMedical.Web.Migrations
b.Navigation("Role"); b.Navigation("Role");
}); });
modelBuilder.Entity("JobsMedical.Web.Models.TalentListing", b =>
{
b.HasOne("JobsMedical.Web.Models.City", "City")
.WithMany()
.HasForeignKey("CityId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("JobsMedical.Web.Models.District", "District")
.WithMany()
.HasForeignKey("DistrictId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("JobsMedical.Web.Models.Role", "Role")
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("City");
b.Navigation("District");
b.Navigation("Role");
});
modelBuilder.Entity("JobsMedical.Web.Models.UserPreferences", b => modelBuilder.Entity("JobsMedical.Web.Models.UserPreferences", b =>
{ {
b.HasOne("JobsMedical.Web.Models.City", "City") b.HasOne("JobsMedical.Web.Models.City", "City")
+4 -2
View File
@@ -69,11 +69,13 @@ public enum EmploymentType
Plan = 3 // طرح Plan = 3 // طرح
} }
/// <summary>What an aggregated/raw listing turned out to be — a shift or a hiring opening.</summary> /// <summary>What an aggregated/raw listing turned out to be — a shift, a hiring opening, or a
/// worker advertising themselves as available («آماده به کار»).</summary>
public enum ListingKind public enum ListingKind
{ {
Shift = 0, Shift = 0,
Job = 1 Job = 1,
Talent = 2
} }
/// <summary>Which listing types a job alert watches.</summary> /// <summary>Which listing types a job alert watches.</summary>
+2
View File
@@ -24,6 +24,8 @@ public class RawListing
public int? LinkedShiftId { get; set; } // شیفت ساخته‌شده از این آگهی public int? LinkedShiftId { get; set; } // شیفت ساخته‌شده از این آگهی
public Shift? LinkedShift { get; set; } public Shift? LinkedShift { get; set; }
public int? LinkedTalentId { get; set; } // آگهی «آماده به کار» ساخته‌شده از این متن
[MaxLength(500)] [MaxLength(500)]
public string? SourceUrl { get; set; } public string? SourceUrl { get; set; }
@@ -0,0 +1,58 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace JobsMedical.Web.Models;
/// <summary>
/// «آماده به کار» — a healthcare worker advertising *themselves* as available for work
/// (the supply side), as opposed to a <see cref="Shift"/>/<see cref="JobOpening"/> posted by a
/// facility (the demand side). Very common in Iranian medical channels ("پرستار آماده همکاری…").
/// There is no facility; the valuable field is the contact <see cref="Phone"/>.
/// </summary>
public class TalentListing
{
public int Id { get; set; }
public int RoleId { get; set; }
public Role Role { get; set; } = null!;
[MaxLength(150)]
public string? PersonName { get; set; } // «دکتر سپیده علیزاده» (best-effort)
public int? YearsExperience { get; set; } // سابقه (سال)
public bool IsLicensed { get; set; } // پروانه‌دار / دارای پروانه
public int CityId { get; set; }
public City City { get; set; } = null!;
public int? DistrictId { get; set; }
public District? District { get; set; }
[MaxLength(150)]
public string? AreaNote { get; set; } // «فقط منطقه ۱» وقتی محله دقیق نگاشت نشد
public EmploymentType? Availability { get; set; } // تمام‌وقت/پاره‌وقت/قراردادی...
public Gender Gender { get; set; } = Gender.Any; // جنسیت فرد
// Expected compensation — reuses the shift/job comp model.
public PayType PayType { get; set; } = PayType.Negotiable;
public long? PayAmount { get; set; } // مبلغ مدنظر (تومان)
public int? SharePercent { get; set; } // درصد/سهم درآمد مدنظر («۵۰٪ تسویه»)
[MaxLength(30)]
public string? Phone { get; set; } // شماره تماس — مهم‌ترین فیلد
[MaxLength(2000)]
public string? Description { get; set; }
public ShiftStatus Status { get; set; } = ShiftStatus.Open;
public ShiftSource Source { get; set; } = ShiftSource.Admin;
[MaxLength(500)]
public string? SourceUrl { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Transient: distance (km) when "near me" is active. Not persisted.
[NotMapped] public double? DistanceKm { get; set; }
}
@@ -60,6 +60,10 @@
{ {
<a class="btn btn-outline" style="padding:4px 12px; font-size:13px;" asp-page="/Shifts/Details" asp-route-id="@sid" target="_blank">مشاهده آگهی منتشرشده</a> <a class="btn btn-outline" style="padding:4px 12px; font-size:13px;" asp-page="/Shifts/Details" asp-route-id="@sid" target="_blank">مشاهده آگهی منتشرشده</a>
} }
else if (r.LinkedTalentId is int tid)
{
<a class="btn btn-outline" style="padding:4px 12px; font-size:13px;" asp-page="/Talent/Details" asp-route-id="@tid" target="_blank">مشاهده «آماده به کار» منتشرشده</a>
}
</div> </div>
} }
} }
+49 -4
View File
@@ -46,9 +46,10 @@
<select name="Kind" id="kindSelect"> <select name="Kind" id="kindSelect">
<option value="0" selected="@(Model.Kind == JobsMedical.Web.Models.ListingKind.Shift)">شیفت</option> <option value="0" selected="@(Model.Kind == JobsMedical.Web.Models.ListingKind.Shift)">شیفت</option>
<option value="1" selected="@(Model.Kind == JobsMedical.Web.Models.ListingKind.Job)">استخدام</option> <option value="1" selected="@(Model.Kind == JobsMedical.Web.Models.ListingKind.Job)">استخدام</option>
<option value="2" selected="@(Model.Kind == JobsMedical.Web.Models.ListingKind.Talent)">آماده به کار (معرفی نیرو)</option>
</select> </select>
</div> </div>
<div class="filter-group"> <div class="filter-group" id="facilityGroup">
<label>مرکز درمانی</label> <label>مرکز درمانی</label>
<select name="FacilityId"> <select name="FacilityId">
<option value="0" selected="@(Model.FacilityId == 0)">— انتخاب نشده —</option> <option value="0" selected="@(Model.FacilityId == 0)">— انتخاب نشده —</option>
@@ -123,6 +124,40 @@
</div> </div>
</div> </div>
<div id="talentFields" style="display:none;">
<div class="filter-group">
<label>نام فرد (اختیاری)</label>
<input type="text" name="PersonName" value="@Model.PersonName" placeholder="مثلاً دکتر سپیده علیزاده" />
</div>
<div class="filter-group">
<label>شهر</label>
<select name="TalentCityId">
@foreach (var c in Model.Cities)
{
<option value="@c.Id" selected="@(Model.TalentCityId == c.Id)">@c.Name</option>
}
</select>
</div>
<div class="filter-group" style="display:flex; gap:8px;">
<div style="flex:1;"><label>سابقه (سال)</label><input type="number" name="YearsExperience" value="@Model.YearsExperience" min="0" max="60" dir="ltr" /></div>
<div style="flex:1;"><label>محدوده کاری</label><input type="text" name="AreaNote" value="@Model.AreaNote" placeholder="مثلاً فقط منطقه ۱" /></div>
</div>
<div class="filter-group">
<label>شماره تماس</label>
<input type="text" name="Phone" value="@Model.Phone" placeholder="۰۹۱۲…" dir="ltr" />
</div>
<div class="filter-group">
<label style="display:flex; align-items:center; gap:8px; font-weight:600;">
<input type="checkbox" name="IsLicensed" value="true" style="width:auto;" checked="@Model.IsLicensed" /> پروانه‌دار
</label>
</div>
<div class="filter-group" style="display:flex; gap:8px;">
<div style="flex:1;"><label>دستمزد مدنظر (تومان)</label><input type="number" name="PayAmount" value="@Model.PayAmount" dir="ltr" /></div>
<div style="flex:1;"><label>یا سهم درآمد (٪)</label><input type="number" name="SharePercent" value="@Model.SharePercent" min="1" max="100" dir="ltr" /></div>
</div>
<p class="muted" style="font-size:11px; margin:4px 0 0;">برای «آماده به کار» نیازی به مرکز نیست؛ شماره تماس مهم‌ترین فیلد است.</p>
</div>
<div class="filter-group"> <div class="filter-group">
<label style="display:flex; align-items:center; gap:8px; font-weight:600;"> <label style="display:flex; align-items:center; gap:8px; font-weight:600;">
<input type="checkbox" name="Negotiable" value="true" style="width:auto;" checked="@Model.Negotiable" /> توافقی <input type="checkbox" name="Negotiable" value="true" style="width:auto;" checked="@Model.Negotiable" /> توافقی
@@ -143,10 +178,20 @@
@section Scripts { @section Scripts {
<script> <script>
var kind = document.getElementById('kindSelect'); var kind = document.getElementById('kindSelect');
var facilityGroup = document.getElementById('facilityGroup');
// Show one section and DISABLE the hidden ones so duplicate-named inputs
// (PayAmount/SharePercent appear in both shift and talent) aren't submitted.
function setSection(el, on) {
if (!el) return;
el.style.display = on ? 'block' : 'none';
el.querySelectorAll('input,select,textarea').forEach(function (i) { i.disabled = !on; });
}
function toggleKind() { function toggleKind() {
var isJob = kind.value === '1'; var v = kind.value;
document.getElementById('jobFields').style.display = isJob ? 'block' : 'none'; setSection(document.getElementById('shiftFields'), v === '0');
document.getElementById('shiftFields').style.display = isJob ? 'none' : 'block'; setSection(document.getElementById('jobFields'), v === '1');
setSection(document.getElementById('talentFields'), v === '2');
setSection(facilityGroup, v !== '2'); // facility only for shift/job
} }
kind.addEventListener('change', toggleKind); kind.addEventListener('change', toggleKind);
toggleKind(); toggleKind();
@@ -27,6 +27,7 @@ public class ReviewModel : PageModel
public ParsedListing? Parsed { get; private set; } public ParsedListing? Parsed { get; private set; }
public List<Facility> Facilities { get; private set; } = new(); public List<Facility> Facilities { get; private set; } = new();
public List<Role> Roles { 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; } [TempData] public string? Error { get; set; }
@@ -50,6 +51,13 @@ public class ReviewModel : PageModel
[BindProperty] public EmploymentType EmploymentType { get; set; } [BindProperty] public EmploymentType EmploymentType { get; set; }
[BindProperty] public long? SalaryMin { get; set; } [BindProperty] public long? SalaryMin { get; set; }
[BindProperty] public long? SalaryMax { 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) public async Task<IActionResult> OnGetAsync(int id)
{ {
@@ -74,6 +82,15 @@ public class ReviewModel : PageModel
Description = Raw.RawText; Description = Raw.RawText;
Title = Parsed.RoleName is not null ? $"استخدام {Parsed.RoleName}" : "موقعیت استخدامی"; 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 // Facility: try to match the listing's facility to one we already have; otherwise
// prefill the "new facility" box so publishing creates it. // prefill the "new facility" box so publishing creates it.
if (!string.IsNullOrWhiteSpace(Parsed.FacilityName)) if (!string.IsNullOrWhiteSpace(Parsed.FacilityName))
@@ -100,21 +117,61 @@ public class ReviewModel : PageModel
Raw = await _db.RawListings.FirstOrDefaultAsync(r => r.Id == id); Raw = await _db.RawListings.FirstOrDefaultAsync(r => r.Id == id);
if (Raw is null) return NotFound(); 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)) if (!await _db.Roles.AnyAsync(r => r.Id == RoleId))
{ {
Error = "یک نقش معتبر انتخاب کن."; Error = "یک نقش معتبر انتخاب کن.";
return RedirectToPage(new { id }); 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; Shift? createdShift = null;
JobOpening? createdJob = null; JobOpening? createdJob = null;
if (Kind == ListingKind.Shift) if (Kind == ListingKind.Shift)
@@ -188,24 +245,27 @@ public class ReviewModel : PageModel
_ => (new TimeOnly(8, 0), new TimeOnly(8, 0)), _ => (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> /// <summary>
/// Returns a valid existing FacilityId, creating a new unverified facility from /// Returns a valid FacilityId. Prefers the picked facility, then the typed/parsed name
/// <see cref="NewFacilityName"/> when nothing valid is selected. Returns null when /// (reusing a fuzzy match before creating), and finally falls back to a single shared
/// neither a valid facility is picked nor a name is provided. /// «نامشخص / ثبت نشده» record so publishing never fails for a missing facility.
/// Returns null only when there are no cities at all.
/// </summary> /// </summary>
private async Task<int?> ResolveFacilityIdAsync() private async Task<int?> ResolveFacilityIdAsync()
{ {
if (FacilityId > 0 && await _db.Facilities.AnyAsync(f => f.Id == FacilityId)) if (FacilityId > 0 && await _db.Facilities.AnyAsync(f => f.Id == FacilityId))
return FacilityId; return FacilityId;
if (string.IsNullOrWhiteSpace(NewFacilityName))
return null;
var name = NewFacilityName.Trim();
var cityId = await _db.Cities.OrderByDescending(c => c.IsActive) var cityId = await _db.Cities.OrderByDescending(c => c.IsActive)
.Select(c => (int?)c.Id).FirstOrDefaultAsync(); .Select(c => (int?)c.Id).FirstOrDefaultAsync();
if (cityId is null) return null; // no cities seeded — cannot create a facility 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 // Reuse an existing facility that's exactly or closely the same (Persian-aware fuzzy
// match), so we don't create duplicates like «بیمارستان میلاد» vs «میلاد». // match), so we don't create duplicates like «بیمارستان میلاد» vs «میلاد».
var all = await _db.Facilities.ToListAsync(); 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(); 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(); 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(); private Task<List<string>> CityNamesAsync() => _db.Cities.Select(c => c.Name).ToListAsync();
+18
View File
@@ -133,6 +133,24 @@
</section> </section>
} }
@if (Model.LatestTalent.Count > 0)
{
<section class="section" style="padding-top:0;">
<div class="container">
<div class="section-head">
<h2>کادر درمان آماده به کار</h2>
<a asp-page="/Talent/Index">مشاهده همه ←</a>
</div>
<div class="grid grid-3">
@foreach (var t in Model.LatestTalent)
{
<partial name="_TalentCard" model="t" />
}
</div>
</div>
</section>
}
<section class="section" style="background: var(--surface); border-top: 1px solid var(--line);"> <section class="section" style="background: var(--surface); border-top: 1px solid var(--line);">
<div class="container"> <div class="container">
<div class="section-head"><h2>چطور کار می‌کند؟</h2></div> <div class="section-head"><h2>چطور کار می‌کند؟</h2></div>
@@ -23,6 +23,7 @@ public class IndexModel : PageModel
public bool HasPersonalization { get; private set; } public bool HasPersonalization { get; private set; }
public List<Shift> LatestShifts { get; private set; } = new(); public List<Shift> LatestShifts { get; private set; } = new();
public List<JobOpening> LatestJobs { get; private set; } = new(); public List<JobOpening> LatestJobs { get; private set; } = new();
public List<TalentListing> LatestTalent { get; private set; } = new();
public List<City> Cities { get; private set; } = new(); public List<City> Cities { get; private set; } = new();
public List<Role> Roles { get; private set; } = new(); public List<Role> Roles { get; private set; } = new();
public int OpenShiftCount { get; private set; } public int OpenShiftCount { get; private set; }
@@ -56,6 +57,14 @@ public class IndexModel : PageModel
.Take(3) .Take(3)
.ToListAsync(); .ToListAsync();
LatestTalent = await _db.TalentListings
.Include(t => t.City).Include(t => t.District).Include(t => t.Role)
.Where(t => t.Status == ShiftStatus.Open
&& t.CreatedAt >= JobsMedical.Web.Services.Scraping.ListingPolicy.JobCutoffUtc)
.OrderByDescending(t => t.CreatedAt)
.Take(3)
.ToListAsync();
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync(); Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync(); Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
OpenShiftCount = await _db.Shifts.CountAsync(s => s.Status == ShiftStatus.Open && s.Date >= today); OpenShiftCount = await _db.Shifts.CountAsync(s => s.Status == ShiftStatus.Open && s.Date >= today);
@@ -113,6 +113,7 @@
<a asp-page="/Index" class="@(path == "/" ? "active" : null)">خانه</a> <a asp-page="/Index" class="@(path == "/" ? "active" : null)">خانه</a>
<a asp-page="/Shifts/Index" data-tour="shifts" class="@(path.StartsWith("/Shifts") ? "active" : null)">شیفت‌ها</a> <a asp-page="/Shifts/Index" data-tour="shifts" class="@(path.StartsWith("/Shifts") ? "active" : null)">شیفت‌ها</a>
<a asp-page="/Jobs/Index" data-tour="jobs" class="@(path.StartsWith("/Jobs") ? "active" : null)">استخدام</a> <a asp-page="/Jobs/Index" data-tour="jobs" class="@(path.StartsWith("/Jobs") ? "active" : null)">استخدام</a>
<a asp-page="/Talent/Index" class="@(path.StartsWith("/Talent") ? "active" : null)">آماده به کار</a>
<a asp-page="/Facilities/Index" class="@(path.StartsWith("/Facilities") ? "active" : null)">مراکز درمانی</a> <a asp-page="/Facilities/Index" class="@(path.StartsWith("/Facilities") ? "active" : null)">مراکز درمانی</a>
<a asp-page="/Calendar/Index" class="@(path.StartsWith("/Calendar") ? "active" : null)">تقویم هفتگی</a> <a asp-page="/Calendar/Index" class="@(path.StartsWith("/Calendar") ? "active" : null)">تقویم هفتگی</a>
</nav> </nav>
@@ -0,0 +1,44 @@
@model JobsMedical.Web.Models.TalentListing
@{
string comp;
if (Model.PayType == JobsMedical.Web.Models.PayType.Percentage && Model.SharePercent is int sp)
comp = $"{JalaliDate.ToPersianDigits(sp.ToString())}٪ سهم درآمد";
else if (Model.PayAmount is long pa && pa > 0)
comp = JalaliDate.Toman(pa) + " مدنظر";
else
comp = "توافقی";
var heading = string.IsNullOrWhiteSpace(Model.PersonName)
? (Model.Role?.Name ?? "آماده به کار")
: Model.PersonName!;
var area = Model.District?.Name ?? Model.AreaNote;
}
<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="badge badge-talent">آماده به کار</span>
</div>
<div class="row">
@if (Model.Role is not null)
{
<span class="badge badge-type">@Model.Role.Name</span>
}
@if (Model.YearsExperience is int yrs && yrs > 0)
{
<span class="badge badge-day">@JalaliDate.ToPersianDigits(yrs.ToString()) سال سابقه</span>
}
@if (Model.IsLicensed)
{
<span class="badge badge-verified">پروانه‌دار</span>
}
@if (Model.Gender != JobsMedical.Web.Models.Gender.Any)
{
<span class="badge badge-gender">@JalaliDate.GenderLabel(Model.Gender)</span>
}
</div>
<div class="row">📍 @Model.City?.Name@(area is not null ? "، " + area : "")</div>
<div class="foot">
<span class="pay">@comp</span>
<span class="btn btn-outline" style="padding: 6px 14px;">مشاهده و تماس</span>
</div>
</a>
@@ -0,0 +1,82 @@
@page "{id:int}"
@model JobsMedical.Web.Pages.Talent.DetailsModel
@{
var t = Model.Item!;
var heading = string.IsNullOrWhiteSpace(t.PersonName) ? (t.Role?.Name ?? "آماده به کار") : t.PersonName!;
ViewData["Title"] = $"{heading} — آماده به کار";
// Personal contact info: keep this page out of search indexes.
ViewData["NoIndex"] = true;
string comp;
if (t.PayType == JobsMedical.Web.Models.PayType.Percentage && t.SharePercent is int sp)
comp = $"{JalaliDate.ToPersianDigits(sp.ToString())}٪ سهم درآمد";
else if (t.PayAmount is long pa && pa > 0)
comp = JalaliDate.Toman(pa) + " مدنظر";
else
comp = "توافقی";
string? telHref = null;
if (!string.IsNullOrWhiteSpace(t.Phone))
{
var digits = new string(t.Phone.Where(char.IsDigit).ToArray());
if (digits.Length >= 7) telHref = "tel:" + digits;
}
}
<div class="page-head">
<div class="container">
<h1>@heading</h1>
<p class="muted">آماده همکاری @(t.Role is not null ? "— " + t.Role.Name : "") · 📍 @t.City?.Name@(t.District?.Name is not null ? "، " + t.District.Name : (t.AreaNote is not null ? "، " + t.AreaNote : ""))</p>
</div>
</div>
<div class="container section">
<div class="detail-grid">
<div>
<div class="card card-pad">
<div class="row" style="gap:8px; flex-wrap:wrap;">
@if (t.Role is not null) { <span class="badge badge-type">@t.Role.Name</span> }
<span class="badge badge-talent">آماده به کار</span>
@if (t.YearsExperience is int yrs && yrs > 0) { <span class="badge badge-day">@JalaliDate.ToPersianDigits(yrs.ToString()) سال سابقه</span> }
@if (t.IsLicensed) { <span class="badge badge-verified">پروانه‌دار</span> }
@if (t.Gender != JobsMedical.Web.Models.Gender.Any) { <span class="badge badge-gender">@JalaliDate.GenderLabel(t.Gender)</span> }
@if (t.Availability is JobsMedical.Web.Models.EmploymentType emp)
{
<span class="badge badge-job">@(emp switch {
JobsMedical.Web.Models.EmploymentType.FullTime => "تمام‌وقت",
JobsMedical.Web.Models.EmploymentType.PartTime => "پاره‌وقت",
JobsMedical.Web.Models.EmploymentType.Contract => "قراردادی",
_ => "طرح" })</span>
}
</div>
@if (!string.IsNullOrWhiteSpace(t.AreaNote))
{
<p style="margin:12px 0 0;"><strong>محدوده کاری:</strong> @t.AreaNote</p>
}
<p style="margin:12px 0 0;"><strong>دستمزد مدنظر:</strong> @comp</p>
@if (!string.IsNullOrWhiteSpace(t.Description))
{
<hr style="border:none; border-top:1px solid var(--line); margin:16px 0;" />
<p style="white-space:pre-wrap; margin:0;">@t.Description</p>
}
</div>
</div>
<aside>
<div class="card card-pad">
<h3 style="margin-top:0;">تماس</h3>
@if (telHref is not null)
{
<a href="@telHref" class="btn btn-accent btn-block btn-lg" dir="ltr">📞 @t.Phone</a>
<p class="muted" style="font-size:12px; margin:10px 0 0;">با این فرد مستقیم تماس بگیرید.</p>
}
else
{
<p class="muted">شماره تماس ثبت نشده است.</p>
}
@if (!string.IsNullOrWhiteSpace(t.SourceUrl))
{
<a href="@t.SourceUrl" target="_blank" rel="nofollow noopener" class="btn btn-outline btn-block" style="margin-top:8px;">منبع آگهی ↗</a>
}
</div>
</aside>
</div>
</div>
@@ -0,0 +1,26 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Talent;
public class DetailsModel : PageModel
{
private readonly AppDbContext _db;
public DetailsModel(AppDbContext db) => _db = db;
public TalentListing? Item { get; private set; }
public async Task<IActionResult> OnGetAsync(int id)
{
Item = await _db.TalentListings
.Include(t => t.City)
.Include(t => t.District)
.Include(t => t.Role)
.FirstOrDefaultAsync(t => t.Id == id);
if (Item is null) return NotFound();
return Page();
}
}
@@ -0,0 +1,81 @@
@page
@model JobsMedical.Web.Pages.Talent.IndexModel
@{
ViewData["Title"] = "آماده به کار — کادر درمان";
ViewData["Description"] = "فهرست کادر درمان آماده همکاری (پزشک، پرستار، ماما، تکنسین و…) در تهران و سایر شهرها — مرکز درمانی می‌تواند مستقیم تماس بگیرد.";
}
<div class="page-head">
<div class="container">
<h1>آماده به کار</h1>
<p class="muted">
@JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) نیروی کادر درمان آماده‌ی همکاری —
مراکز درمانی می‌توانند مستقیم تماس بگیرند.
</p>
</div>
</div>
<div class="container section">
<div class="layout-2">
<aside class="card card-pad filter-card">
<h3>فیلترها</h3>
<form method="get" id="filterForm">
<div class="filter-group">
<label>شهر</label>
<select name="CityId" onchange="this.form.submit()">
<option value="">همه شهرها</option>
@foreach (var c in Model.Cities)
{
<option value="@c.Id" selected="@(Model.CityId == c.Id)">@c.Name</option>
}
</select>
</div>
<div class="filter-group">
<label>محله / منطقه</label>
<select name="DistrictId" onchange="this.form.submit()">
<option value="">همه محله‌ها</option>
@foreach (var d in Model.Districts)
{
<option value="@d.Id" selected="@(Model.DistrictId == d.Id)">@d.Name</option>
}
</select>
</div>
<div class="filter-group">
<label>نقش / رشته</label>
<select name="RoleId" onchange="this.form.submit()">
<option value="">همه نقش‌ها</option>
@foreach (var r in Model.Roles)
{
<option value="@r.Id" selected="@(Model.RoleId == r.Id)">@r.Name</option>
}
</select>
</div>
<div class="filter-group">
<label>جنسیت</label>
<select name="GenderFilter" onchange="this.form.submit()">
<option value="">فرقی نمی‌کند</option>
<option value="1" selected="@(Model.GenderFilter == JobsMedical.Web.Models.Gender.Male)">آقا</option>
<option value="2" selected="@(Model.GenderFilter == JobsMedical.Web.Models.Gender.Female)">خانم</option>
</select>
</div>
<a asp-page="/Talent/Index" class="btn btn-outline btn-block">حذف فیلترها</a>
</form>
</aside>
<div>
@if (Model.Results.Count == 0)
{
<div class="card empty-state">نیرویی با این فیلترها پیدا نشد.</div>
}
else
{
<div class="grid grid-3">
@foreach (var t in Model.Results)
{
<partial name="_TalentCard" model="t" />
}
</div>
}
</div>
</div>
</div>
@@ -0,0 +1,47 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using JobsMedical.Web.Services.Scraping;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Talent;
public class IndexModel : PageModel
{
private readonly AppDbContext _db;
public IndexModel(AppDbContext db) => _db = db;
[BindProperty(SupportsGet = true)] public int? CityId { get; set; }
[BindProperty(SupportsGet = true)] public int? DistrictId { get; set; }
[BindProperty(SupportsGet = true)] public int? RoleId { get; set; }
[BindProperty(SupportsGet = true)] public Gender? GenderFilter { get; set; }
public List<TalentListing> Results { get; private set; } = new();
public List<City> Cities { get; private set; } = new();
public List<District> Districts { get; private set; } = new();
public List<Role> Roles { get; private set; } = new();
public async Task OnGetAsync()
{
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
Districts = await _db.Districts
.Where(d => d.IsActive && (CityId == null || d.CityId == CityId))
.OrderBy(d => d.Name).ToListAsync();
var q = _db.TalentListings
.Include(t => t.City)
.Include(t => t.District)
.Include(t => t.Role)
.Where(t => t.Status == ShiftStatus.Open && t.CreatedAt >= ListingPolicy.JobCutoffUtc);
if (CityId is not null) q = q.Where(t => t.CityId == CityId);
if (DistrictId is not null) q = q.Where(t => t.DistrictId == DistrictId);
if (RoleId is not null) q = q.Where(t => t.RoleId == RoleId);
if (GenderFilter is Gender g && g != Gender.Any)
q = q.Where(t => t.Gender == Gender.Any || t.Gender == g);
Results = await q.OrderByDescending(t => t.CreatedAt).ToListAsync();
}
}
+3 -1
View File
@@ -312,6 +312,8 @@ app.MapGet("/robots.txt", (HttpContext ctx) =>
"Disallow: /Admin", "Disallow: /Employer", "Disallow: /Me", "Disallow: /Account", "Disallow: /Admin", "Disallow: /Employer", "Disallow: /Me", "Disallow: /Account",
"Disallow: /Preferences", "Disallow: /resume/", "Disallow: /avatar/", "Disallow: /Preferences", "Disallow: /resume/", "Disallow: /avatar/",
"Disallow: /report", "Disallow: /push/", "Disallow: /notifications/", "Disallow: /report", "Disallow: /push/", "Disallow: /notifications/",
"Disallow: /Talent/Details", // personal contact info — list page stays indexable
$"Sitemap: {b}/sitemap.xml", ""); $"Sitemap: {b}/sitemap.xml", "");
return Results.Text(rules, "text/plain"); return Results.Text(rules, "text/plain");
}); });
@@ -332,7 +334,7 @@ app.MapGet("/sitemap.xml", async (HttpContext ctx, AppDbContext db) =>
sb.Append("<changefreq>").Append(freq).Append("</changefreq></url>\n"); sb.Append("<changefreq>").Append(freq).Append("</changefreq></url>\n");
} }
foreach (var p in new[] { "", "/Shifts", "/Jobs", "/Calendar", "/Facilities" }) foreach (var p in new[] { "", "/Shifts", "/Jobs", "/Talent", "/Calendar", "/Facilities" })
Url($"{b}{p}", DateTime.UtcNow, p == "" ? "daily" : "hourly"); Url($"{b}{p}", DateTime.UtcNow, p == "" ? "daily" : "hourly");
// Static content pages (rarely change). // Static content pages (rarely change).
foreach (var p in new[] { "/Download", "/Help", "/Privacy", "/Rules", "/Terms" }) foreach (var p in new[] { "/Download", "/Help", "/Privacy", "/Rules", "/Terms" })
+77 -6
View File
@@ -18,6 +18,12 @@ public class ParsedListing
public string? DistrictName { get; set; } public string? DistrictName { get; set; }
public string? FacilityName { get; set; } // hospital/clinic name guessed from the text public string? FacilityName { get; set; } // hospital/clinic name guessed from the text
public string? Phone { get; set; } public string? Phone { get; set; }
// «آماده به کار» (talent) extras — populated when Kind == Talent.
public string? PersonName { get; set; } // «دکتر سپیده علیزاده»
public int? YearsExperience { get; set; } // سابقه (سال)
public bool IsLicensed { get; set; } // پروانه‌دار
public string? AreaNote { get; set; } // «فقط منطقه ۱»
public List<string> Notes { get; set; } = new(); // what was/wasn't detected (shown to admin) public List<string> Notes { get; set; } = new(); // what was/wasn't detected (shown to admin)
} }
@@ -41,11 +47,25 @@ public class HeuristicListingParser : IListingParser
var p = new ParsedListing(); var p = new ParsedListing();
var text = Normalize(raw); var text = Normalize(raw);
// --- Kind: shift vs hiring --- // --- Kind: talent (worker offers themselves) vs shift vs hiring ---
bool jobSignals = ContainsAny(text, "استخدام", "جذب", "دعوت به همکاری", "تمام وقت", "تمام‌وقت", "قرارداد", "ماهانه", "حقوق ثابت"); // Talent is checked first: «آماده به کار/همکاری», «جویای کار» mean the *person* is
// available — distinct from an employer's «دعوت به همکاری».
bool talentSignals = ContainsAny(text,
"آماده به کار", "آماده‌به‌کار", "آماده همکاری", "آماده‌ی همکاری", "آماده ی همکاری",
"آماده فعالیت", "جویای کار", "جویای کار هستم", "متقاضی کار", "نیازمند کار",
"آماده انجام", "می‌توانم همکاری", "میتوانم همکاری", "حاضر به همکاری");
bool jobSignals = ContainsAny(text, "استخدام", "جذب", "دعوت به همکاری", "نیازمندیم", "نیازمند است", "حقوق ثابت");
bool shiftSignals = ContainsAny(text, "شیفت", "آنکال", "انکال", "نوبت", "کشیک"); bool shiftSignals = ContainsAny(text, "شیفت", "آنکال", "انکال", "نوبت", "کشیک");
p.Kind = (jobSignals && !shiftSignals) ? ListingKind.Job : ListingKind.Shift; if (talentSignals)
p.Notes.Add(p.Kind == ListingKind.Job ? "نوع: استخدام (تشخیص خودکار)" : "نوع: شیفت (تشخیص خودکار)"); {
p.Kind = ListingKind.Talent;
p.Notes.Add("نوع: آماده به کار (تشخیص خودکار)");
}
else
{
p.Kind = (jobSignals && !shiftSignals) ? ListingKind.Job : ListingKind.Shift;
p.Notes.Add(p.Kind == ListingKind.Job ? "نوع: استخدام (تشخیص خودکار)" : "نوع: شیفت (تشخیص خودکار)");
}
// --- Role (longest match first so «پزشک متخصص» beats «پزشک») --- // --- Role (longest match first so «پزشک متخصص» beats «پزشک») ---
foreach (var role in knownRoles.OrderByDescending(r => r.Length)) foreach (var role in knownRoles.OrderByDescending(r => r.Length))
@@ -108,9 +128,31 @@ public class HeuristicListingParser : IListingParser
else if (p.SharePercent is null) p.Notes.Add("حقوق: تشخیص داده نشد"); else if (p.SharePercent is null) p.Notes.Add("حقوق: تشخیص داده نشد");
} }
// --- Talent extras (only meaningful for «آماده به کار») ---
if (p.Kind == ListingKind.Talent)
{
var latinT = ToLatinDigits(text);
var exp = Regex.Match(latinT, @"سابقه[^\d]{0,8}(\d{1,2})\s*سال");
if (!exp.Success) exp = Regex.Match(latinT, @"(\d{1,2})\s*سال\s*سابقه");
if (exp.Success && int.TryParse(exp.Groups[1].Value, out var yrs) && yrs is > 0 and <= 60)
{ p.YearsExperience = yrs; p.Notes.Add($"سابقه: {yrs} سال"); }
p.IsLicensed = ContainsAny(text, "پروانه دار", "پروانه‌دار", "دارای پروانه", "پروانه فعالیت", "پروانه طبابت");
if (p.IsLicensed) p.Notes.Add("پروانه‌دار");
p.PersonName = ExtractPersonName(text);
if (p.PersonName is not null) p.Notes.Add($"نام: {p.PersonName}");
var area = Regex.Match(text, @"منطقه\s*[۰-۹0-9]{1,2}");
if (area.Success) { p.AreaNote = area.Value.Trim(); p.Notes.Add($"محدوده: {p.AreaNote}"); }
}
// --- Facility name (بیمارستان/درمانگاه/کلینیک ... + the distinctive name) --- // --- Facility name (بیمارستان/درمانگاه/کلینیک ... + the distinctive name) ---
p.FacilityName = ExtractFacilityName(text); if (p.Kind != ListingKind.Talent)
if (p.FacilityName is not null) p.Notes.Add($"مرکز: {p.FacilityName}"); {
p.FacilityName = ExtractFacilityName(text);
if (p.FacilityName is not null) p.Notes.Add($"مرکز: {p.FacilityName}");
}
// --- Phone --- // --- Phone ---
var phone = Regex.Match(ToLatinDigits(text), @"0?9\d{9}"); var phone = Regex.Match(ToLatinDigits(text), @"0?9\d{9}");
@@ -161,6 +203,35 @@ public class HeuristicListingParser : IListingParser
return null; return null;
} }
// Titles that introduce a person's name in «آماده به کار» posts.
private static readonly string[] PersonTitles = { "دکتر", "خانم دکتر", "آقای دکتر", "مهندس", "سرکار خانم", "جناب آقای", "خانم", "آقای" };
/// <summary>Best-effort person name: a title (دکتر/خانم/…) plus up to two following words.</summary>
private static string? ExtractPersonName(string text)
{
foreach (var title in PersonTitles)
{
var idx = text.IndexOf(title, StringComparison.Ordinal);
if (idx < 0) continue;
var after = text[(idx + title.Length)..];
var words = after.Split(
new[] { ' ', '\n', '\r', '\t', '،', ',', '.', '؛', ':', '(', ')', '-', '/' },
StringSplitOptions.RemoveEmptyEntries);
var picked = new List<string>();
foreach (var w in words)
{
if (NameStops.Contains(w)) break;
if (Regex.IsMatch(w, @"[\d]")) break;
if (w.Length == 1) break;
picked.Add(w);
if (picked.Count >= 2) break;
}
if (picked.Count == 0) continue;
return (title + " " + string.Join(" ", picked)).Trim();
}
return null;
}
/// <summary>Pull a Toman figure out of free text, handling «میلیون» and Persian digits.</summary> /// <summary>Pull a Toman figure out of free text, handling «میلیون» and Persian digits.</summary>
private static long? ExtractAmount(string text) private static long? ExtractAmount(string text)
{ {
@@ -170,6 +170,27 @@ public class IngestionService
?? cities.FirstOrDefault(c => c.IsActive) ?? cities.First(); ?? cities.FirstOrDefault(c => c.IsActive) ?? cities.First();
var district = districts.FirstOrDefault(x => x.Name == districtName && x.CityId == city.Id); var district = districts.FirstOrDefault(x => x.Name == districtName && x.CityId == city.Id);
var kindStr = (d?.Kind ?? parsed.Kind.ToString()).ToLowerInvariant();
// «آماده به کار» — a worker offering themselves. No facility involved.
if (parsed.Kind == ListingKind.Talent || kindStr.Contains("talent") || kindStr.Contains("آماده"))
{
_db.TalentListings.Add(new TalentListing
{
Role = role, City = city, DistrictId = district?.Id,
PersonName = parsed.PersonName, YearsExperience = parsed.YearsExperience,
IsLicensed = parsed.IsLicensed, AreaNote = parsed.AreaNote,
Availability = parsed.EmploymentType, Gender = parsed.Gender,
PayType = parsed.SharePercent is not null && parsed.PayAmount is null ? PayType.Percentage
: parsed.PayAmount is null ? PayType.Negotiable : PayType.PerShift,
PayAmount = parsed.PayAmount, SharePercent = parsed.SharePercent,
Phone = parsed.Phone, Description = raw.RawText,
Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = raw.SourceUrl,
});
raw.Status = RawListingStatus.Normalized;
return;
}
var facilityName = !string.IsNullOrWhiteSpace(d?.FacilityName) ? d!.FacilityName!.Trim() var facilityName = !string.IsNullOrWhiteSpace(d?.FacilityName) ? d!.FacilityName!.Trim()
: !string.IsNullOrWhiteSpace(parsed.FacilityName) ? parsed.FacilityName!.Trim() : !string.IsNullOrWhiteSpace(parsed.FacilityName) ? parsed.FacilityName!.Trim()
: $"مرکز درمانی (از {raw.SourceChannel})"; : $"مرکز درمانی (از {raw.SourceChannel})";
@@ -186,8 +207,7 @@ public class IngestionService
facilities.Add(facility); // so later listings in this run match it too facilities.Add(facility); // so later listings in this run match it too
} }
var kind = (d?.Kind ?? parsed.Kind.ToString()).ToLowerInvariant(); if (kindStr.Contains("job") || kindStr.Contains("استخدام"))
if (kind.Contains("job") || kind.Contains("استخدام"))
{ {
_db.JobOpenings.Add(new JobOpening _db.JobOpenings.Add(new JobOpening
{ {
@@ -42,8 +42,13 @@ public class ListingArchiver
.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt < jobCutoff) .Where(j => j.Status == ShiftStatus.Open && j.CreatedAt < jobCutoff)
.ExecuteUpdateAsync(u => u.SetProperty(j => j.Status, ShiftStatus.Expired), ct); .ExecuteUpdateAsync(u => u.SetProperty(j => j.Status, ShiftStatus.Expired), ct);
if (expiredShifts + expiredJobs > 0) var expiredTalent = await _db.TalentListings
_log.LogInformation("Archived {Shifts} shifts + {Jobs} jobs as expired", expiredShifts, expiredJobs); .Where(t => t.Status == ShiftStatus.Open && t.CreatedAt < jobCutoff)
return expiredShifts + expiredJobs; .ExecuteUpdateAsync(u => u.SetProperty(t => t.Status, ShiftStatus.Expired), ct);
if (expiredShifts + expiredJobs + expiredTalent > 0)
_log.LogInformation("Archived {Shifts} shifts + {Jobs} jobs + {Talent} talent as expired",
expiredShifts, expiredJobs, expiredTalent);
return expiredShifts + expiredJobs + expiredTalent;
} }
} }
@@ -43,6 +43,23 @@ public class ListingValidator
bool looksMedical = MedicalMarkers.Any(text.Contains); bool looksMedical = MedicalMarkers.Any(text.Contains);
if (!looksMedical) issues.Add("نشانه‌ای از حوزه درمان یافت نشد"); if (!looksMedical) issues.Add("نشانه‌ای از حوزه درمان یافت نشد");
// «آماده به کار»: a worker offering themselves. No facility/shift-date expected; the role
// and a contact number are what matter.
if (parsed.Kind == ListingKind.Talent)
{
int ts = 0;
if (parsed.RoleName is not null) ts += 35; else issues.Add("نقش/رشته مشخص نیست");
if (parsed.Phone is not null) ts += 30; else issues.Add("شماره تماس یافت نشد");
if (parsed.CityName is not null || parsed.DistrictName is not null || parsed.AreaNote is not null) ts += 15;
if (parsed.YearsExperience is not null || parsed.IsLicensed) ts += 10;
if (looksMedical) ts += 10;
var tlen = text.Trim().Length;
if (tlen < 20) { ts -= 20; issues.Add("متن خیلی کوتاه است"); }
ts = Math.Clamp(ts, 0, 100);
bool tValid = !isSpam && looksMedical && ts >= 50;
return new ValidationResult(tValid, isSpam, ts, issues);
}
int score = 0; int score = 0;
if (parsed.RoleName is not null) score += 30; else issues.Add("نقش مشخص نیست"); if (parsed.RoleName is not null) score += 30; else issues.Add("نقش مشخص نیست");
if (parsed.CityName is not null || parsed.DistrictName is not null) score += 20; if (parsed.CityName is not null || parsed.DistrictName is not null) score += 20;
+1
View File
@@ -267,6 +267,7 @@ label { font-size: 13px; }
.badge-type { background: #eef3f6; color: var(--muted); } .badge-type { background: #eef3f6; color: var(--muted); }
.badge-distance { background: #fff1e8; color: #c5670f; } .badge-distance { background: #fff1e8; color: #c5670f; }
.badge-job { background: #eaf3ff; color: #2563eb; } .badge-job { background: #eaf3ff; color: #2563eb; }
.badge-talent { background: #fdece8; color: var(--accent, #e25c43); }
.badge-gender { background: #f3eefb; color: #6b3fa0; } .badge-gender { background: #f3eefb; color: #6b3fa0; }
/* ---------- Filters layout ---------- */ /* ---------- Filters layout ---------- */