fix(inventory): enable smart warehouse stock binding and optional brand/specs for inter-project transfer
This commit is contained in:
@@ -100,6 +100,56 @@ async def create_project_transfer(
|
||||
specs_hash,
|
||||
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:
|
||||
avail = source_item.quantity if source_item else Decimal("0")
|
||||
raise ValidationError(
|
||||
@@ -129,16 +179,20 @@ async def create_project_transfer(
|
||||
db.add(out_tx)
|
||||
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(
|
||||
from_project_id=from_wh.project_id,
|
||||
from_warehouse_id=from_wh.id,
|
||||
to_project_id=payload.to_project_id,
|
||||
material_id=payload.material_id,
|
||||
brand_id=payload.brand_id,
|
||||
unit_id=payload.unit_id,
|
||||
brand_id=actual_brand_id,
|
||||
unit_id=source_item.unit_id if source_item.unit_id else payload.unit_id,
|
||||
quantity=payload.quantity,
|
||||
specs=specs,
|
||||
specs_hash=specs_hash,
|
||||
specs=actual_specs,
|
||||
specs_hash=actual_specs_hash,
|
||||
note=payload.note,
|
||||
status="in_transit",
|
||||
out_transaction_id=out_tx.id,
|
||||
|
||||
@@ -238,17 +238,12 @@ export function ChangeInventoryPage() {
|
||||
}
|
||||
}, [type, loadTransfers]);
|
||||
|
||||
const matchedStock = useMemo(() => {
|
||||
if (type !== "PROJECT_TRANSFER" || !whId || !matTxt.trim()) return null;
|
||||
const mt = matTxt.trim();
|
||||
const bt = brandTxt.trim();
|
||||
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 whStock = useMemo(() => {
|
||||
if (!whId) return [];
|
||||
return (data.inventory || []).filter((r) => r.warehouse?.id === whId && r.quantity_number > 0);
|
||||
}, [data.inventory, whId]);
|
||||
|
||||
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editingTransfer) return;
|
||||
@@ -340,36 +335,52 @@ export function ChangeInventoryPage() {
|
||||
if (!whId) return toast.error("انبار مبدأ را انتخاب کنید");
|
||||
if (!toProjectId) return toast.error("پروژه مقصد را انتخاب کنید");
|
||||
if (!matName) return toast.error("نام متریال را وارد کنید");
|
||||
if (!brandName) return toast.error("نام برند را وارد کنید");
|
||||
if (!unitName) return toast.error("نام واحد را وارد کنید");
|
||||
if (!(n > 0)) return toast.error("تعداد باید بزرگتر از صفر باشد");
|
||||
if (!specsComplete) return toast.error("حداقل یک مشخصه فنی ثبت کنید");
|
||||
|
||||
setBusy(true);
|
||||
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) {
|
||||
const created = await apiV26.catalog.createUnit(unitName);
|
||||
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);
|
||||
bid = created.id;
|
||||
}
|
||||
let m = matByName(matName);
|
||||
if (!m) {
|
||||
const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] });
|
||||
m = { id: created.id, name: created.name, canonical_unit: null };
|
||||
|
||||
let mid = matchedInv?.material.id;
|
||||
if (!mid) {
|
||||
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({
|
||||
from_warehouse_id: whId,
|
||||
to_project_id: toProjectId,
|
||||
material_id: m.id,
|
||||
brand_id: bid,
|
||||
material_id: mid,
|
||||
brand_id: bid || null,
|
||||
unit_id: uid,
|
||||
quantity: n,
|
||||
specs: specsFromChips(),
|
||||
specs: finalSpecs,
|
||||
note,
|
||||
transfer_date: toServerIso(),
|
||||
}, user?.project_id);
|
||||
@@ -530,7 +541,7 @@ export function ChangeInventoryPage() {
|
||||
return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete);
|
||||
}
|
||||
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 === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
||||
@@ -766,6 +777,69 @@ export function ChangeInventoryPage() {
|
||||
{/* فرم خرید / موجودی اولیه یا انتقال بین کارگاهی */}
|
||||
{(type === "PURCHASE" || type === "PROJECT_TRANSFER") && (
|
||||
<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">
|
||||
<label className="text-xs font-semibold text-muted">متریال *</label>
|
||||
<Combobox
|
||||
@@ -784,13 +858,15 @@ export function ChangeInventoryPage() {
|
||||
)}
|
||||
</div>
|
||||
<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
|
||||
value={brandTxt}
|
||||
items={data.lookup.brands.map((b) => b.name)}
|
||||
allowAdd
|
||||
addSuffix="به عنوان برند جدید"
|
||||
placeholder="نام برند — از فهرست انتخاب یا نام جدید تایپ کنید"
|
||||
placeholder={type === "PROJECT_TRANSFER" ? "نام برند (اختیاری — برای اقلام بدون برند خالی بگذارید)" : "نام برند — از فهرست انتخاب یا نام جدید تایپ کنید"}
|
||||
onInputChange={setBrandTxt}
|
||||
onChange={(it) => setBrandTxt(it.label)}
|
||||
/>
|
||||
@@ -827,11 +903,13 @@ export function ChangeInventoryPage() {
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
values={specsChips}
|
||||
onChange={setSpecsChips}
|
||||
placeholder="نام مشخصه را تایپ کنید و Enter بزنید (مثلاً سایز)"
|
||||
placeholder={type === "PROJECT_TRANSFER" ? "مشخصات فنی (اختیاری — مثلاً مسی، سایز و…)" : "نام مشخصه را تایپ کنید و Enter بزنید (مثلاً سایز)"}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -844,20 +922,6 @@ export function ChangeInventoryPage() {
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user