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:
@@ -19,6 +19,7 @@ public class AppDbContext : DbContext, IDataProtectionKeyContext
|
||||
public DbSet<Facility> Facilities => Set<Facility>();
|
||||
public DbSet<Shift> Shifts => Set<Shift>();
|
||||
public DbSet<JobOpening> JobOpenings => Set<JobOpening>();
|
||||
public DbSet<TalentListing> TalentListings => Set<TalentListing>();
|
||||
public DbSet<Application> Applications => Set<Application>();
|
||||
public DbSet<RawListing> RawListings => Set<RawListing>();
|
||||
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.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<Notification>()
|
||||
|
||||
+1473
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")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("LinkedTalentId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ParsedJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -887,6 +890,86 @@ namespace JobsMedical.Web.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -1282,6 +1365,32 @@ namespace JobsMedical.Web.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("JobsMedical.Web.Models.City", "City")
|
||||
|
||||
@@ -69,11 +69,13 @@ public enum EmploymentType
|
||||
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
|
||||
{
|
||||
Shift = 0,
|
||||
Job = 1
|
||||
Job = 1,
|
||||
Talent = 2
|
||||
}
|
||||
|
||||
/// <summary>Which listing types a job alert watches.</summary>
|
||||
|
||||
@@ -24,6 +24,8 @@ public class RawListing
|
||||
public int? LinkedShiftId { get; set; } // شیفت ساختهشده از این آگهی
|
||||
public Shift? LinkedShift { get; set; }
|
||||
|
||||
public int? LinkedTalentId { get; set; } // آگهی «آماده به کار» ساختهشده از این متن
|
||||
|
||||
[MaxLength(500)]
|
||||
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>
|
||||
}
|
||||
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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,9 +46,10 @@
|
||||
<select name="Kind" id="kindSelect">
|
||||
<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="2" selected="@(Model.Kind == JobsMedical.Web.Models.ListingKind.Talent)">آماده به کار (معرفی نیرو)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<div class="filter-group" id="facilityGroup">
|
||||
<label>مرکز درمانی</label>
|
||||
<select name="FacilityId">
|
||||
<option value="0" selected="@(Model.FacilityId == 0)">— انتخاب نشده —</option>
|
||||
@@ -123,6 +124,40 @@
|
||||
</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">
|
||||
<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" /> توافقی
|
||||
@@ -143,10 +178,20 @@
|
||||
@section Scripts {
|
||||
<script>
|
||||
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() {
|
||||
var isJob = kind.value === '1';
|
||||
document.getElementById('jobFields').style.display = isJob ? 'block' : 'none';
|
||||
document.getElementById('shiftFields').style.display = isJob ? 'none' : 'block';
|
||||
var v = kind.value;
|
||||
setSection(document.getElementById('shiftFields'), v === '0');
|
||||
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);
|
||||
toggleKind();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -133,6 +133,24 @@
|
||||
</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);">
|
||||
<div class="container">
|
||||
<div class="section-head"><h2>چطور کار میکند؟</h2></div>
|
||||
|
||||
@@ -23,6 +23,7 @@ public class IndexModel : PageModel
|
||||
public bool HasPersonalization { get; private set; }
|
||||
public List<Shift> LatestShifts { 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<Role> Roles { get; private set; } = new();
|
||||
public int OpenShiftCount { get; private set; }
|
||||
@@ -56,6 +57,14 @@ public class IndexModel : PageModel
|
||||
.Take(3)
|
||||
.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();
|
||||
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);
|
||||
|
||||
@@ -113,6 +113,7 @@
|
||||
<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="/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="/Calendar/Index" class="@(path.StartsWith("/Calendar") ? "active" : null)">تقویم هفتگی</a>
|
||||
</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();
|
||||
}
|
||||
}
|
||||
@@ -312,6 +312,8 @@ app.MapGet("/robots.txt", (HttpContext ctx) =>
|
||||
"Disallow: /Admin", "Disallow: /Employer", "Disallow: /Me", "Disallow: /Account",
|
||||
"Disallow: /Preferences", "Disallow: /resume/", "Disallow: /avatar/",
|
||||
"Disallow: /report", "Disallow: /push/", "Disallow: /notifications/",
|
||||
"Disallow: /Talent/Details", // personal contact info — list page stays indexable
|
||||
|
||||
$"Sitemap: {b}/sitemap.xml", "");
|
||||
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");
|
||||
}
|
||||
|
||||
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");
|
||||
// Static content pages (rarely change).
|
||||
foreach (var p in new[] { "/Download", "/Help", "/Privacy", "/Rules", "/Terms" })
|
||||
|
||||
@@ -18,6 +18,12 @@ public class ParsedListing
|
||||
public string? DistrictName { get; set; }
|
||||
public string? FacilityName { get; set; } // hospital/clinic name guessed from the text
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -41,11 +47,25 @@ public class HeuristicListingParser : IListingParser
|
||||
var p = new ParsedListing();
|
||||
var text = Normalize(raw);
|
||||
|
||||
// --- Kind: shift vs hiring ---
|
||||
bool jobSignals = ContainsAny(text, "استخدام", "جذب", "دعوت به همکاری", "تمام وقت", "تماموقت", "قرارداد", "ماهانه", "حقوق ثابت");
|
||||
// --- Kind: talent (worker offers themselves) vs shift vs hiring ---
|
||||
// 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, "شیفت", "آنکال", "انکال", "نوبت", "کشیک");
|
||||
if (talentSignals)
|
||||
{
|
||||
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 «پزشک») ---
|
||||
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("حقوق: تشخیص داده نشد");
|
||||
}
|
||||
|
||||
// --- 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) ---
|
||||
if (p.Kind != ListingKind.Talent)
|
||||
{
|
||||
p.FacilityName = ExtractFacilityName(text);
|
||||
if (p.FacilityName is not null) p.Notes.Add($"مرکز: {p.FacilityName}");
|
||||
}
|
||||
|
||||
// --- Phone ---
|
||||
var phone = Regex.Match(ToLatinDigits(text), @"0?9\d{9}");
|
||||
@@ -161,6 +203,35 @@ public class HeuristicListingParser : IListingParser
|
||||
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>
|
||||
private static long? ExtractAmount(string text)
|
||||
{
|
||||
|
||||
@@ -170,6 +170,27 @@ public class IngestionService
|
||||
?? cities.FirstOrDefault(c => c.IsActive) ?? cities.First();
|
||||
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()
|
||||
: !string.IsNullOrWhiteSpace(parsed.FacilityName) ? parsed.FacilityName!.Trim()
|
||||
: $"مرکز درمانی (از {raw.SourceChannel})";
|
||||
@@ -186,8 +207,7 @@ public class IngestionService
|
||||
facilities.Add(facility); // so later listings in this run match it too
|
||||
}
|
||||
|
||||
var kind = (d?.Kind ?? parsed.Kind.ToString()).ToLowerInvariant();
|
||||
if (kind.Contains("job") || kind.Contains("استخدام"))
|
||||
if (kindStr.Contains("job") || kindStr.Contains("استخدام"))
|
||||
{
|
||||
_db.JobOpenings.Add(new JobOpening
|
||||
{
|
||||
|
||||
@@ -42,8 +42,13 @@ public class ListingArchiver
|
||||
.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt < jobCutoff)
|
||||
.ExecuteUpdateAsync(u => u.SetProperty(j => j.Status, ShiftStatus.Expired), ct);
|
||||
|
||||
if (expiredShifts + expiredJobs > 0)
|
||||
_log.LogInformation("Archived {Shifts} shifts + {Jobs} jobs as expired", expiredShifts, expiredJobs);
|
||||
return expiredShifts + expiredJobs;
|
||||
var expiredTalent = await _db.TalentListings
|
||||
.Where(t => t.Status == ShiftStatus.Open && t.CreatedAt < jobCutoff)
|
||||
.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);
|
||||
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;
|
||||
if (parsed.RoleName is not null) score += 30; else issues.Add("نقش مشخص نیست");
|
||||
if (parsed.CityName is not null || parsed.DistrictName is not null) score += 20;
|
||||
|
||||
@@ -267,6 +267,7 @@ label { font-size: 13px; }
|
||||
.badge-type { background: #eef3f6; color: var(--muted); }
|
||||
.badge-distance { background: #fff1e8; color: #c5670f; }
|
||||
.badge-job { background: #eaf3ff; color: #2563eb; }
|
||||
.badge-talent { background: #fdece8; color: var(--accent, #e25c43); }
|
||||
.badge-gender { background: #f3eefb; color: #6b3fa0; }
|
||||
|
||||
/* ---------- Filters layout ---------- */
|
||||
|
||||
Reference in New Issue
Block a user