fix(inventory): enable smart warehouse stock binding and optional brand/specs for inter-project transfer

This commit is contained in:
2026-09-23 23:11:18 +03:30
parent e560018273
commit b2aba2fcfe
2 changed files with 164 additions and 46 deletions
@@ -100,6 +100,56 @@ async def create_project_transfer(
specs_hash, specs_hash,
for_update=True, for_update=True,
) )
# Smart fallback: If exact hash or brand didn't match, check candidate rows in this warehouse for this material
if source_item is None:
candidates_res = await db.execute(
select(InventoryItem)
.where(
InventoryItem.warehouse_id == payload.from_warehouse_id,
InventoryItem.material_id == payload.material_id,
)
.with_for_update()
)
candidates = candidates_res.scalars().all()
if len(candidates) == 1:
source_item = candidates[0]
elif len(candidates) > 1:
best_cand = None
best_score = -1
p_keys = set(specs.keys())
p_vals = set(str(v).strip() for v in specs.values() if str(v).strip())
for cand in candidates:
score = 0
if cand.brand_id == payload.brand_id:
score += 10
elif cand.brand_id is None and payload.brand_id is None:
score += 10
elif cand.brand_id is None:
score += 4
c_specs = cand.specs or {}
c_keys = set(c_specs.keys())
c_vals = set(str(v).strip() for v in c_specs.values() if str(v).strip())
for pk in p_keys:
if pk in c_keys or f"spec_{pk}" in c_keys or any(pk in ck for ck in c_keys):
score += 5
for pv in p_vals:
if pv in c_vals or any(pv in str(cv) for cv in c_vals):
score += 5
if cand.quantity >= payload.quantity:
score += 2
if score > best_score:
best_score = score
best_cand = cand
source_item = best_cand
if source_item is None or source_item.quantity < payload.quantity: if source_item is None or source_item.quantity < payload.quantity:
avail = source_item.quantity if source_item else Decimal("0") avail = source_item.quantity if source_item else Decimal("0")
raise ValidationError( raise ValidationError(
@@ -129,16 +179,20 @@ async def create_project_transfer(
db.add(out_tx) db.add(out_tx)
await db.flush() await db.flush()
actual_brand_id = source_item.brand_id
actual_specs = source_item.specs if source_item.specs is not None else specs
actual_specs_hash = source_item.specs_hash or canonical_specs_hash(actual_specs)
transfer = ProjectTransfer( transfer = ProjectTransfer(
from_project_id=from_wh.project_id, from_project_id=from_wh.project_id,
from_warehouse_id=from_wh.id, from_warehouse_id=from_wh.id,
to_project_id=payload.to_project_id, to_project_id=payload.to_project_id,
material_id=payload.material_id, material_id=payload.material_id,
brand_id=payload.brand_id, brand_id=actual_brand_id,
unit_id=payload.unit_id, unit_id=source_item.unit_id if source_item.unit_id else payload.unit_id,
quantity=payload.quantity, quantity=payload.quantity,
specs=specs, specs=actual_specs,
specs_hash=specs_hash, specs_hash=actual_specs_hash,
note=payload.note, note=payload.note,
status="in_transit", status="in_transit",
out_transaction_id=out_tx.id, out_transaction_id=out_tx.id,
@@ -238,17 +238,12 @@ export function ChangeInventoryPage() {
} }
}, [type, loadTransfers]); }, [type, loadTransfers]);
const matchedStock = useMemo(() => { const whStock = useMemo(() => {
if (type !== "PROJECT_TRANSFER" || !whId || !matTxt.trim()) return null; if (!whId) return [];
const mt = matTxt.trim(); return (data.inventory || []).filter((r) => r.warehouse?.id === whId && r.quantity_number > 0);
const bt = brandTxt.trim(); }, [data.inventory, whId]);
return data.inventory.filter((r) => {
if (r.warehouse?.id !== whId) return false;
if (r.material.name !== mt) return false;
if (bt && r.brand?.name !== bt) return false;
return true;
});
}, [type, whId, matTxt, brandTxt, data.inventory]);
const handleSaveEdit = async () => { const handleSaveEdit = async () => {
if (!editingTransfer) return; if (!editingTransfer) return;
@@ -340,36 +335,52 @@ export function ChangeInventoryPage() {
if (!whId) return toast.error("انبار مبدأ را انتخاب کنید"); if (!whId) return toast.error("انبار مبدأ را انتخاب کنید");
if (!toProjectId) return toast.error("پروژه مقصد را انتخاب کنید"); if (!toProjectId) return toast.error("پروژه مقصد را انتخاب کنید");
if (!matName) return toast.error("نام متریال را وارد کنید"); if (!matName) return toast.error("نام متریال را وارد کنید");
if (!brandName) return toast.error("نام برند را وارد کنید");
if (!unitName) return toast.error("نام واحد را وارد کنید"); if (!unitName) return toast.error("نام واحد را وارد کنید");
if (!(n > 0)) return toast.error("تعداد باید بزرگ‌تر از صفر باشد"); if (!(n > 0)) return toast.error("تعداد باید بزرگ‌تر از صفر باشد");
if (!specsComplete) return toast.error("حداقل یک مشخصه فنی ثبت کنید");
setBusy(true); setBusy(true);
try { try {
let uid = unitIdByName(unitName); // Find matching inventory in the selected source warehouse
const invInWh = (data.inventory || []).filter(
(r) => r.warehouse?.id === whId && r.material.name.trim() === matName
);
const matchedInv = invInWh.find(
(r) => (brandName ? r.brand?.name === brandName : !r.brand)
) || invInWh[0];
let uid = matchedInv?.unit?.id ?? unitIdByName(unitName);
if (!uid) { if (!uid) {
const created = await apiV26.catalog.createUnit(unitName); const created = await apiV26.catalog.createUnit(unitName);
uid = created.id; uid = created.id;
} }
let bid = brandIdByName(brandName);
if (!bid) { let bid: string | null = matchedInv?.brand?.id ?? (brandName ? brandIdByName(brandName) : null);
if (brandName && !bid) {
const created = await apiV26.catalog.createBrand(brandName); const created = await apiV26.catalog.createBrand(brandName);
bid = created.id; bid = created.id;
} }
let m = matByName(matName);
if (!m) { let mid = matchedInv?.material.id;
const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] }); if (!mid) {
m = { id: created.id, name: created.name, canonical_unit: null }; let m = matByName(matName);
if (!m) {
const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] });
mid = created.id;
} else {
mid = m.id;
}
} }
const finalSpecs = matchedInv ? (matchedInv.specs || {}) : specsFromChips();
await apiV26.projectTransfers.create({ await apiV26.projectTransfers.create({
from_warehouse_id: whId, from_warehouse_id: whId,
to_project_id: toProjectId, to_project_id: toProjectId,
material_id: m.id, material_id: mid,
brand_id: bid, brand_id: bid || null,
unit_id: uid, unit_id: uid,
quantity: n, quantity: n,
specs: specsFromChips(), specs: finalSpecs,
note, note,
transfer_date: toServerIso(), transfer_date: toServerIso(),
}, user?.project_id); }, user?.project_id);
@@ -530,7 +541,7 @@ export function ChangeInventoryPage() {
return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete); return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete);
} }
if (type === "PROJECT_TRANSFER") { if (type === "PROJECT_TRANSFER") {
return !!(whId && toProjectId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete); return !!(whId && toProjectId && matTxt.trim() && unitTxt.trim() && parseNum(qty) > 0);
} }
if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0)); if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0)); if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
@@ -766,6 +777,69 @@ export function ChangeInventoryPage() {
{/* فرم خرید / موجودی اولیه یا انتقال بین کارگاهی */} {/* فرم خرید / موجودی اولیه یا انتقال بین کارگاهی */}
{(type === "PURCHASE" || type === "PROJECT_TRANSFER") && ( {(type === "PURCHASE" || type === "PROJECT_TRANSFER") && (
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
{/* چیپ‌های انتخاب سریع از موجودی‌های انبار مبدأ */}
{type === "PROJECT_TRANSFER" && whId && (
<div className="field md:col-span-2 rounded-xl border border-rose-500/20 bg-rose-500/5 p-3.5">
<div className="flex items-center justify-between gap-2 mb-2">
<span className="text-xs font-bold text-foreground flex items-center gap-1.5">
<span>📦</span>
<span>موجودی‌های موجود در {whName(warehouses, whId)} — برای پر کردن خودکار فرم کلیک کنید:</span>
</span>
<span className="text-[11px] text-muted">{faNum(whStock.length)} قلم کالا</span>
</div>
{whStock.length === 0 ? (
<div className="text-xs text-muted py-1">هیچ کالایی با موجودی مثبت در این انبار یافت نشد.</div>
) : (
<div className="flex flex-wrap gap-2">
{whStock.map((r) => {
const isSelected = matTxt === r.material.name && (brandTxt === (r.brand?.name || "") || (!brandTxt && !r.brand));
const specsSummary = Object.entries(r.specs || {})
.map(([k, v]) => (v ? `${v}` : k.replace(/^spec_/, "")))
.filter(Boolean)
.join(" · ");
return (
<button
key={r.id}
type="button"
onClick={() => {
setMatTxt(r.material.name);
setBrandTxt(r.brand?.name || "");
setUnitTxt(unitName(data.lookup.units, r.unit?.id) || "");
const chips = Object.entries(r.specs || {})
.map(([k, v]) => (v ? `${v}` : k.replace(/^spec_/, "")))
.filter(Boolean);
setSpecsChips(chips);
}}
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs text-start transition-all shadow-2xs",
isSelected
? "border-rose-500 bg-rose-600 text-white font-bold ring-2 ring-rose-500/30"
: "border-line bg-panel hover:border-ink/40 text-ink"
)}
>
<span className="font-bold">{r.material.name}</span>
<span className={isSelected ? "text-white/80" : "text-muted font-normal"}>
({r.brand?.name || "بدون برند"})
</span>
{specsSummary && (
<span className={isSelected ? "text-white/90" : "text-muted text-[11px]"}>
[{specsSummary}]
</span>
)}
<span className={cn(
"rounded-md px-1.5 py-0.5 text-[11.5px] font-black",
isSelected ? "bg-white/20 text-white" : "bg-bg text-foreground"
)}>
{faNum(r.quantity_number)} {unitName(data.lookup.units, r.unit?.id)}
</span>
</button>
);
})}
</div>
)}
</div>
)}
<div className="field md:col-span-2"> <div className="field md:col-span-2">
<label className="text-xs font-semibold text-muted">متریال *</label> <label className="text-xs font-semibold text-muted">متریال *</label>
<Combobox <Combobox
@@ -784,13 +858,15 @@ export function ChangeInventoryPage() {
)} )}
</div> </div>
<div className="field"> <div className="field">
<label className="text-xs font-semibold text-muted">برند *</label> <label className="text-xs font-semibold text-muted">
برند {type === "PROJECT_TRANSFER" ? "(اختیاری)" : "*"}
</label>
<Combobox <Combobox
value={brandTxt} value={brandTxt}
items={data.lookup.brands.map((b) => b.name)} items={data.lookup.brands.map((b) => b.name)}
allowAdd allowAdd
addSuffix="به عنوان برند جدید" addSuffix="به عنوان برند جدید"
placeholder="نام برند — از فهرست انتخاب یا نام جدید تایپ کنید" placeholder={type === "PROJECT_TRANSFER" ? "نام برند (اختیاری — برای اقلام بدون برند خالی بگذارید)" : "نام برند — از فهرست انتخاب یا نام جدید تایپ کنید"}
onInputChange={setBrandTxt} onInputChange={setBrandTxt}
onChange={(it) => setBrandTxt(it.label)} onChange={(it) => setBrandTxt(it.label)}
/> />
@@ -827,11 +903,13 @@ export function ChangeInventoryPage() {
/> />
</div> </div>
<div className="field md:col-span-2"> <div className="field md:col-span-2">
<label className="text-xs font-semibold text-muted">مشخصه‌های فنی *</label> <label className="text-xs font-semibold text-muted">
مشخصه‌های فنی {type === "PROJECT_TRANSFER" ? "(اختیاری)" : "*"}
</label>
<MultiSelectCombobox <MultiSelectCombobox
values={specsChips} values={specsChips}
onChange={setSpecsChips} onChange={setSpecsChips}
placeholder="نام مشخصه را تایپ کنید و Enter بزنید (مثلاً سایز)" placeholder={type === "PROJECT_TRANSFER" ? "مشخصات فنی (اختیاری — مثلاً مسی، سایز و…)" : "نام مشخصه را تایپ کنید و Enter بزنید (مثلاً سایز)"}
className="w-full" className="w-full"
/> />
</div> </div>
@@ -844,20 +922,6 @@ export function ChangeInventoryPage() {
onChange={(e) => setNote(e.target.value)} onChange={(e) => setNote(e.target.value)}
/> />
</div> </div>
{type === "PROJECT_TRANSFER" && (matchedStock?.length ?? 0) > 0 && (
<div className="field md:col-span-2 rounded-lg border border-line/70 bg-bg/50 p-3 text-xs">
<div className="font-semibold text-muted mb-1.5">موجودی‌های فعلی این متریال در انبار مبدأ ({whName(warehouses, whId)}):</div>
<div className="flex flex-wrap gap-2">
{(matchedStock || []).map((s) => (
<div key={s.id} className="rounded-md border border-line bg-panel px-2.5 py-1 text-[11.5px] shadow-2xs">
<span className="font-medium">{s.brand?.name || "بدون برند"}</span>
<span className="text-muted"> — {Object.entries(s.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "بدون مشخصه"} — </span>
<b className="text-foreground">{faNum(s.quantity_number)} {unitName(data.lookup.units, s.unit?.id)}</b>
</div>
))}
</div>
</div>
)}
</div> </div>
)} )}