feat(tradein): CRM-поля в форме оценки (#395)

Доп. поля для трейд-ин менеджера: тип собственности, ипотека/
обременение, имя и телефон клиента. На расчёт оценки не влияют —
сохраняются в trade_in_estimates вместе с записью.

- data/sql/008_crm_fields.sql — 4 колонки в trade_in_estimates.
- TradeInEstimateInput + estimator INSERT — приём и сохранение.
- EstimateForm — секция «CRM — для менеджера» (4 поля).

Closes #395
This commit is contained in:
TradeIn Deploy 2026-05-22 11:15:15 +05:00
parent 88d2840edf
commit 48ccbdafc8
5 changed files with 101 additions and 0 deletions

View file

@ -22,6 +22,11 @@ class TradeInEstimateInput(BaseModel):
house_type: Literal["panel", "brick", "monolith", "monolith_brick", "other"] | None = None
repair_state: Literal["needs_repair", "standard", "good", "excellent"] | None = None
has_balcony: bool | None = None
# CRM-поля (#395) — операционные, на расчёт оценки не влияют
ownership_type: str | None = Field(default=None, max_length=100)
has_mortgage: bool | None = None
client_name: str | None = Field(default=None, max_length=200)
client_phone: str | None = Field(default=None, max_length=50)
class AnalogLot(BaseModel):

View file

@ -195,6 +195,7 @@ async def estimate_quality(
id, address, lat, lon,
area_m2, rooms, floor, total_floors,
year_built, house_type, repair_state, has_balcony,
ownership_type, has_mortgage, client_name, client_phone,
median_price, range_low, range_high, median_price_per_m2,
confidence, confidence_explanation, n_analogs,
analogs, actual_deals,
@ -205,6 +206,7 @@ async def estimate_quality(
:address, :lat, :lon,
:area, :rooms, :floor, :total_floors,
:year_built, :house_type, :repair_state, :has_balcony,
:ownership_type, :has_mortgage, :client_name, :client_phone,
:median_price, :range_low, :range_high, :median_ppm2,
:confidence, :explanation, :n_analogs,
CAST(:analogs_json AS jsonb),
@ -228,6 +230,10 @@ async def estimate_quality(
"house_type": target_house_type,
"repair_state": payload.repair_state,
"has_balcony": payload.has_balcony,
"ownership_type": payload.ownership_type,
"has_mortgage": payload.has_mortgage,
"client_name": payload.client_name,
"client_phone": payload.client_phone,
"median_price": median_price,
"range_low": range_low,
"range_high": range_high,

View file

@ -0,0 +1,16 @@
-- Trade-In MVP — CRM-поля оценки (#395).
-- Операционные поля трейд-ин менеджера: тип собственности, ипотека/обременение,
-- контакт клиента. На расчёт оценки не влияют — хранятся вместе с записью.
BEGIN;
ALTER TABLE trade_in_estimates
ADD COLUMN IF NOT EXISTS ownership_type text,
ADD COLUMN IF NOT EXISTS has_mortgage boolean,
ADD COLUMN IF NOT EXISTS client_name text,
ADD COLUMN IF NOT EXISTS client_phone text;
COMMENT ON COLUMN trade_in_estimates.client_phone IS
'#395 — контакт клиента (CRM). PII — доступ только у трейд-ин менеджера.';
COMMIT;

View file

@ -25,6 +25,10 @@ interface FormState {
house_type: string;
repair_state: string;
has_balcony: boolean;
ownership_type: string;
has_mortgage: boolean;
client_name: string;
client_phone: string;
}
const INITIAL: FormState = {
@ -37,6 +41,10 @@ const INITIAL: FormState = {
house_type: "",
repair_state: "",
has_balcony: false,
ownership_type: "",
has_mortgage: false,
client_name: "",
client_phone: "",
};
function validate(f: FormState): string | null {
@ -69,6 +77,10 @@ function toPayload(f: FormState): TradeInEstimateInput {
if (f.year_built !== "") out.year_built = Number(f.year_built);
if (f.house_type) out.house_type = f.house_type as HouseType;
if (f.repair_state) out.repair_state = f.repair_state as RepairState;
if (f.ownership_type) out.ownership_type = f.ownership_type;
if (f.has_mortgage) out.has_mortgage = true;
if (f.client_name.trim()) out.client_name = f.client_name.trim();
if (f.client_phone.trim()) out.client_phone = f.client_phone.trim();
return out;
}
@ -271,6 +283,63 @@ export function EstimateForm({ onSubmit, isPending, error }: Props) {
</label>
</div>
{/* CRM — для менеджера (#395) */}
<div style={{ borderTop: "1px solid var(--border, #e5e5e5)", paddingTop: 14, marginTop: 4 }}>
<div style={{ marginBottom: 10, fontSize: 11, fontWeight: 600, color: "var(--muted)", textTransform: "uppercase", letterSpacing: 0.4 }}>
CRM для менеджера
</div>
<div className="field">
<label className="field-label" htmlFor="ownership">
Тип собственности <span className="hint">опц.</span>
</label>
<select className="control" id="ownership" value={form.ownership_type} onChange={field("ownership_type")}>
<option value=""> не указано </option>
<option value="индивидуальная">Индивидуальная</option>
<option value="долевая">Долевая</option>
<option value="совместная">Совместная</option>
</select>
</div>
<div className="field">
<label className="field-label" style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }}>
<input
type="checkbox"
checked={form.has_mortgage}
onChange={field("has_mortgage")}
style={{ width: 14, height: 14, cursor: "pointer" }}
/>
<span style={{ color: "var(--fg)" }}>Ипотека / обременение</span>
</label>
</div>
<div className="field-row">
<div className="field">
<label className="field-label" htmlFor="cname">
Имя клиента <span className="hint">опц.</span>
</label>
<input
className="control"
id="cname"
type="text"
placeholder="Иван Петров"
value={form.client_name}
onChange={field("client_name")}
/>
</div>
<div className="field">
<label className="field-label" htmlFor="cphone">
Телефон <span className="hint">опц.</span>
</label>
<input
className="control"
id="cphone"
type="tel"
placeholder="+7 999 …"
value={form.client_phone}
onChange={field("client_phone")}
/>
</div>
</div>
</div>
{touched && valError && (
<div
style={{

View file

@ -22,6 +22,11 @@ export interface TradeInEstimateInput {
house_type?: HouseType;
repair_state?: RepairState;
has_balcony?: boolean;
// CRM-поля (#395)
ownership_type?: string;
has_mortgage?: boolean;
client_name?: string;
client_phone?: string;
}
export interface AnalogLot {