From 64d80e15f8040bdd660a15e8c5ebe3b7e60e370f Mon Sep 17 00:00:00 2001 From: "reza.g" Date: Wed, 23 Sep 2026 22:02:16 +0330 Subject: [PATCH 1/8] feat(seed): include ali.mohaghegh admin user in seed plan --- backend/tools/seed.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/tools/seed.py b/backend/tools/seed.py index 0e97c7c..35c7237 100644 --- a/backend/tools/seed.py +++ b/backend/tools/seed.py @@ -128,6 +128,7 @@ async def get_category(db, slug: str) -> MaterialCategory: USER_PLAN = [ # (casdoor username, first, last, role) + ("ali.mohaghegh", "علی", "محقق", "admin"), ("razaei", "علی", "رضایی", "admin"), ("mousavi", "حسین", "موسوی", "procurement_manager"), ("mohaghegh", "محمد", "محقق", "pm"), From b6a1ce2ae73e6700785980131d7cddcafdbcdcbe Mon Sep 17 00:00:00 2001 From: "reza.g" Date: Wed, 23 Sep 2026 22:02:24 +0330 Subject: [PATCH 2/8] feat(procurement): support multi-project line item split allocation and merge on reopen - Add ProjectAllocationIn and allocations field to ItemDecisionIn schema - Implement line item split allocation across projects in accept_quote - Replicate all ItemTermsheet options and LineItemSpec records to split rows - Ensure QuoteProjects and LineItemProjects relations are updated - Add validation against duplicate project selection and decimal sum mismatches - Implement _merge_split_line_items_on_reopen to consolidate rows when reverting status to offered --- backend/app/schemas/procurement.py | 6 + backend/app/services/quote_service.py | 197 ++++++++++++++++++++++++++ 2 files changed, 203 insertions(+) diff --git a/backend/app/schemas/procurement.py b/backend/app/schemas/procurement.py index 2d6f0b6..372131b 100644 --- a/backend/app/schemas/procurement.py +++ b/backend/app/schemas/procurement.py @@ -135,6 +135,11 @@ class AcceptTermsheetIn(APIModel): schedule_steps: list[ScheduleStepIn] = Field(min_length=1) +class ProjectAllocationIn(APIModel): + project_id: UUID + quantity: Decimal = Field(gt=0, description="Allocated quantity for this project") + + class ItemDecisionIn(APIModel): line_item_id: UUID status: Literal["chosen", "rejected"] @@ -142,6 +147,7 @@ class ItemDecisionIn(APIModel): adjusted_unit_price: Decimal | None = Field(default=None, gt=0) adjusted_notes: str | None = None project_id: UUID | None = Field(default=None, description="The ONE purchased project (v2.6 — required when the offer spans several projects)") + allocations: list[ProjectAllocationIn] = Field(default_factory=list, description="Multi-project split allocations for this line item") class ItemProjectsIn(APIModel): diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index f776986..8b18746 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -7,6 +7,7 @@ same transaction. import asyncio import logging from datetime import date, datetime, timezone +from decimal import Decimal from uuid import UUID from sqlalchemy import delete, func, select, update @@ -18,6 +19,7 @@ from app.models.procurement import ( ItemTermsheet, LineItem, LineItemProjects, + LineItemSpec, Project, Quote, QuoteEvent, @@ -418,6 +420,78 @@ async def replace_quote_children( +async def _ensure_quote_project(db: AsyncSession, quote_id: UUID, project_id: UUID) -> None: + """Ensure (quote_id, project_id) exists in quote_projects junction.""" + exists = await db.scalar( + select(QuoteProjects).where( + QuoteProjects.quote_id == quote_id, + QuoteProjects.project_id == project_id, + ) + ) + if not exists: + db.add(QuoteProjects(quote_id=quote_id, project_id=project_id)) + + +async def _merge_split_line_items_on_reopen(db: AsyncSession, quote_id: UUID) -> None: + """Consolidate split line items when reverting a quote back to offered. + + Line items sharing the exact same material, brand_material, unit, notes, + and identical specs are merged into a single consolidated row whose quantity + is the sum of all split rows. Secondary split rows are safely deleted. + """ + line_items = ( + await db.execute( + select(LineItem).where(LineItem.quote_id == quote_id).order_by(LineItem.created_at.asc(), LineItem.id.asc()) + ) + ).scalars().all() + if len(line_items) <= 1: + return + + # Load all specs for these line items to compare canonical signatures + specs_rows = ( + await db.execute( + select(LineItemSpec).where(LineItemSpec.line_item_id.in_([li.id for li in line_items])) + ) + ).scalars().all() + specs_by_li: dict[UUID, list[tuple]] = {} + for sp in specs_rows: + sig = ( + str(sp.attribute_definition_id), + str(sp.value_number) if sp.value_number is not None else "", + (sp.value_string or "").strip(), + str(sp.value_boolean) if sp.value_boolean is not None else "", + str(sp.value_date) if sp.value_date is not None else "", + ) + specs_by_li.setdefault(sp.line_item_id, []).append(sig) + + # Group line items by identity + groups: dict[tuple, list[LineItem]] = {} + for li in line_items: + specs_sig = tuple(sorted(specs_by_li.get(li.id, []))) + key = ( + li.material_id, + li.brand_material_id, + li.unit_id, + (li.notes or "").strip(), + specs_sig, + ) + groups.setdefault(key, []).append(li) + + for key, items in groups.items(): + if len(items) > 1: + primary = items[0] + total_qty = sum(Decimal(str(it.quantity)) for it in items) + primary.quantity = total_qty + + secondary_ids = [it.id for it in items[1:]] + await db.execute(delete(ItemDecision).where(ItemDecision.line_item_id.in_(secondary_ids))) + await db.execute(delete(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(secondary_ids))) + await db.execute(delete(LineItemProjects).where(LineItemProjects.line_item_id.in_(secondary_ids))) + await db.execute(delete(LineItemSpec).where(LineItemSpec.line_item_id.in_(secondary_ids))) + await db.execute(delete(LineItem).where(LineItem.id.in_(secondary_ids))) + await db.flush() + + async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None: """Backward transitions only; reverts bookkeeping when going back to offered.""" current = quote.status @@ -454,6 +528,8 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s ) .execution_options(synchronize_session=False) ) + # Requirement 2: Merge split line items back into single rows + await _merge_split_line_items_on_reopen(db, quote.id) # LineItemProjects (candidate projects) are preserved untouched! elif new_status == "accepted": # arrived -> accepted quote.arrived_date = None @@ -557,6 +633,125 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn, "برای قلم انتخاب‌شده adjusted_unit_price الزامی است", field="item_decisions" ) termsheet_id = ts_ids_by_index[index] + + li_row = await db.scalar( + select(LineItem).where(LineItem.id == decision_in.line_item_id).with_for_update() + ) + if li_row is None: + raise NotFoundError("خط آیتم یافت نشد", field="item_decisions.line_item_id") + + allocations = decision_in.allocations or [] + if allocations: + # Requirement 3: Validate no duplicate projects within allocations of this item + alloc_pids = [a.project_id for a in allocations] + if len(alloc_pids) != len(set(alloc_pids)): + raise ValidationError( + "پروژه تکراری برای یک قلم کالا در ردیف‌های تخصیص انتخاب شده است", + field="item_decisions.allocations", + ) + # Requirement 5: Decimal precision check for sum of allocation quantities + alloc_sum = sum(Decimal(str(a.quantity)) for a in allocations) + target_qty = Decimal(str(li_row.quantity)) + if alloc_sum != target_qty: + raise ValidationError( + f"مجموع مقادیر تخصیص‌یافته ({alloc_sum}) با مقدار کل قلم ({target_qty}) برابر نیست", + field="item_decisions.allocations", + ) + # Validate all projects exist + for a in allocations: + if await db.get(Project, a.project_id) is None: + raise NotFoundError("پروژه یافت نشد", field="item_decisions.allocations.project_id") + + if len(allocations) >= 2: + # 1. Update the original line item to the first allocation quantity + first_alloc = allocations[0] + li_row.quantity = first_alloc.quantity + decision_in.project_id = first_alloc.project_id + + # Query all existing item_termsheets and line_item_specs for copying + orig_termsheets = ( + await db.execute( + select(ItemTermsheet).where(ItemTermsheet.line_item_id == li_row.id) + ) + ).scalars().all() + orig_specs = ( + await db.execute( + select(LineItemSpec).where(LineItemSpec.line_item_id == li_row.id) + ) + ).scalars().all() + + # 2. For each subsequent allocation, create a new LineItem and copy specs + ALL termsheets + for split_alloc in allocations[1:]: + new_li = LineItem( + quote_id=quote.id, + material_id=li_row.material_id, + brand_material_id=li_row.brand_material_id, + unit_id=li_row.unit_id, + quantity=split_alloc.quantity, + notes=li_row.notes, + ) + db.add(new_li) + await db.flush() + + # Copy LineItemSpec + for sp in orig_specs: + db.add( + LineItemSpec( + line_item_id=new_li.id, + attribute_definition_id=sp.attribute_definition_id, + value_number=sp.value_number, + value_string=sp.value_string, + value_boolean=sp.value_boolean, + value_date=sp.value_date, + unit_id_at_time=sp.unit_id_at_time, + ) + ) + + # Requirement 1: Copy ALL payment options (ItemTermsheet) + chosen_new_it: ItemTermsheet | None = None + for it_orig in orig_termsheets: + price = ( + decision_in.adjusted_unit_price + if (it_orig.termsheet_id == termsheet_id and decision_in.adjusted_unit_price is not None) + else it_orig.unit_price + ) + copied_it = ItemTermsheet( + line_item_id=new_li.id, + termsheet_id=it_orig.termsheet_id, + unit_price=price, + min_order_qty=it_orig.min_order_qty, + ) + db.add(copied_it) + if it_orig.termsheet_id == termsheet_id: + chosen_new_it = copied_it + + if chosen_new_it is None: + chosen_new_it = ItemTermsheet( + line_item_id=new_li.id, + termsheet_id=termsheet_id, + unit_price=decision_in.adjusted_unit_price, + ) + db.add(chosen_new_it) + await db.flush() + + # Create ItemDecision for the split line item + new_decision = ItemDecision( + line_item_id=new_li.id, + status="chosen", + chosen_item_termsheet_id=chosen_new_it.id, + adjusted_unit_price=decision_in.adjusted_unit_price, + adjusted_notes=decision_in.adjusted_notes, + project_id=split_alloc.project_id, + decided_at=utcnow(), + ) + db.add(new_decision) + + # Requirement 4: sync QuoteProjects & LineItemProjects + await _ensure_quote_project(db, quote.id, split_alloc.project_id) + db.add(LineItemProjects(line_item_id=new_li.id, project_id=split_alloc.project_id)) + elif len(allocations) == 1: + decision_in.project_id = allocations[0].project_id + it = await db.scalar( select(ItemTermsheet).where( ItemTermsheet.line_item_id == decision_in.line_item_id, @@ -610,6 +805,8 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn, if decision.project_id is not None: if await db.get(Project, decision.project_id) is None: raise NotFoundError("پروژه یافت نشد", field="item_decisions.project_id") + # Requirement 4: sync QuoteProjects + await _ensure_quote_project(db, quote.id, decision.project_id) chosen_ts_ids.add(termsheet_id) else: # rejected if decision is None: From da883a28c055b77db44c70f8a2cf95cbcf95ca55 Mon Sep 17 00:00:00 2001 From: "reza.g" Date: Wed, 23 Sep 2026 22:02:49 +0330 Subject: [PATCH 3/8] feat(dashboard): add multi-project allocation UI in AcceptModal and merge on reopen in EditModal - Add allocations field to LineItem and propagate in api.quotes.update - Implement allocation cards in AcceptModal Section 3 with per-row project select and sum validation - Disable previously selected projects within the same item to prevent duplicates - Merge split items client-side in EditModal when transitioning status to offered --- frontend/src/lib/api.ts | 27 +- frontend/src/lib/types.ts | 1 + frontend/src/pages/Dashboard/AcceptModal.tsx | 329 ++++++++++++++++--- frontend/src/pages/Dashboard/EditModal.tsx | 19 ++ 4 files changed, 322 insertions(+), 54 deletions(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6e5f6ca..365a067 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -927,13 +927,28 @@ export const api = { if (becomingAccepted) { const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted"); - // پروژه‌ی واحدِ انتخاب‌شده برای هر قلم (item_decisions.project_id) + // پروژه‌ی واحدِ انتخاب‌شده یا تخصیص‌های چندپروژه‌ای برای هر قلم const targetProjByLine = new Map(); + const allocationsByLine = new Map(); for (const li of patch.line_items || []) { - const tp = li.target_project; - if (tp && tp.name) { - const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name); - if (pid) targetProjByLine.set(li.id, pid); + if (li.allocations && li.allocations.length > 0) { + const resolvedAllocs: { project_id: string; quantity: number }[] = []; + for (const a of li.allocations) { + const pid = looksLikeUuid(a.project_id) ? a.project_id! : await projectIdByNameOrCreate(a.project_name); + if (pid && a.quantity > 0) { + resolvedAllocs.push({ project_id: pid, quantity: Number(a.quantity) }); + } + } + if (resolvedAllocs.length) { + allocationsByLine.set(li.id, resolvedAllocs); + targetProjByLine.set(li.id, resolvedAllocs[0].project_id); + } + } else { + const tp = li.target_project; + if (tp && tp.name) { + const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name); + if (pid) targetProjByLine.set(li.id, pid); + } } } @@ -953,11 +968,13 @@ export const api = { const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1; const chosen = !li.rejected && idx >= 0; const chosenProjId = targetProjByLine.get(li.id); + const lineAllocs = allocationsByLine.get(li.id); return { line_item_id: li.id, status: chosen ? "chosen" : "rejected", ...(chosen ? { accepted_termsheet_index: idx } : {}), ...(chosen && chosenProjId ? { project_id: chosenProjId } : {}), + ...(chosen && lineAllocs && lineAllocs.length ? { allocations: lineAllocs } : {}), ...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}), }; }), diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index dad95b0..e026a84 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -73,6 +73,7 @@ export interface LineItem { projects?: { id: string; name: string }[]; // پروژه‌های کاندیدای تخصیص‌یافته به این قلم (line_item_projects) target_project?: { id: string; name: string } | null; // پروژه‌ی نهایی انتخاب‌شده در هنگام خرید (item_decisions.project_id) target_project_id?: string | null; + allocations?: { project_id?: string; project_name: string; quantity: number }[]; // تخصیص چندپروژه‌ای در پذیرش } // پیشنهاد خرید (اصلی‌ترین entity) diff --git a/frontend/src/pages/Dashboard/AcceptModal.tsx b/frontend/src/pages/Dashboard/AcceptModal.tsx index df384e0..6d499d1 100644 --- a/frontend/src/pages/Dashboard/AcceptModal.tsx +++ b/frontend/src/pages/Dashboard/AcceptModal.tsx @@ -23,6 +23,7 @@ import { import { MilestoneRow } from "../../components/MilestoneRow"; import { FileList } from "../../components/FileList"; import { JalaliDatePicker } from "../../components/JalaliDatePicker"; +import { Building2, Plus, Trash2, CheckCircle2, AlertCircle } from "lucide-react"; interface CardDraft { domId: string; @@ -51,6 +52,9 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { const [removedItems, setRemovedItems] = useState>(new Set()); const [assignments, setAssignments] = useState>({}); const [itemProjectsSel, setItemProjectsSel] = useState>({}); + const [itemAllocations, setItemAllocations] = useState< + Record + >({}); const [acceptDate, setAcceptDate] = useState(() => new Date().toISOString()); const [ready, setReady] = useState(false); @@ -67,17 +71,35 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { setRemovedItems(rem); setAssignments({}); const projSel: Record = {}; + const allocs: Record = {}; q.line_items.forEach((li) => { const names = (li.projects || []).map((p) => p.name).filter(Boolean); - projSel[li.id] = li.target_project?.name + const defaultProj = li.target_project?.name ? li.target_project.name : names.length ? names[0] : q.projects && q.projects.length ? q.projects[0] : q.project || ""; + projSel[li.id] = defaultProj; + if (li.allocations && li.allocations.length) { + allocs[li.id] = li.allocations.map((a, idx) => ({ + rowId: `alloc-${li.id}-${idx}`, + project_name: a.project_name, + quantity: a.quantity, + })); + } else { + allocs[li.id] = [ + { + rowId: `alloc-${li.id}-0`, + project_name: defaultProj, + quantity: li.quantity ?? 1, + }, + ]; + } }); setItemProjectsSel(projSel); + setItemAllocations(allocs); setReady(true); }, [q]); @@ -137,6 +159,44 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { setAssignments((a) => ({ ...a, [liId]: domId })); }; + const addAllocationRow = (liId: string, totalQty: number) => { + setItemAllocations((prev) => { + const currentList = prev[liId] || []; + const currentSum = currentList.reduce((sum, a) => sum + (parseFloat(String(a.quantity)) || 0), 0); + const remaining = Math.max(0, Math.round((totalQty - currentSum) * 10000) / 10000); + const usedProjects = new Set(currentList.map((a) => a.project_name).filter(Boolean)); + const firstAvailable = projectOptions.find((p) => !usedProjects.has(p)) || ""; + const newRow = { + rowId: `alloc-${liId}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + project_name: firstAvailable, + quantity: remaining > 0 ? remaining : "", + }; + return { ...prev, [liId]: [...currentList, newRow] }; + }); + }; + + const removeAllocationRow = (liId: string, rowId: string) => { + setItemAllocations((prev) => { + const currentList = prev[liId] || []; + if (currentList.length <= 1) return prev; + return { ...prev, [liId]: currentList.filter((a) => a.rowId !== rowId) }; + }); + }; + + const updateAllocationRow = ( + liId: string, + rowId: string, + patch: Partial<{ project_name: string; quantity: number | string }>, + ) => { + setItemAllocations((prev) => { + const currentList = prev[liId] || []; + return { + ...prev, + [liId]: currentList.map((a) => (a.rowId === rowId ? { ...a, ...patch } : a)), + }; + }); + }; + const confirm = () => { if (!q) return; @@ -206,13 +266,64 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered"; }); - // پروژه‌ی هر قلم (تکی هدف خرید) — فقط target_project تنظیم می‌شود؛ - // li.projects و next.projects دست‌نخورده باقی می‌مانند تا پروژه‌های کاندیدای اولیه حفظ شوند + // اعتبارسنجی تخصیص‌های چندپروژه‌ای به ازای هر قلم (الزامات ۳ و ۵) + for (const li of active) { + const allocs = itemAllocations[li.id] || []; + const m = materialById(materials, li.material_id); + const title = formatMaterialTitle(m); + const targetQty = Number(li.quantity ?? 0); + + if (!allocs.length) { + toast.error(`برای قلم «${title}» حداقل یک ردیف تخصیص به پروژه لازم است.`); + return; + } + + for (const a of allocs) { + if (!a.project_name.trim()) { + toast.error(`برای قلم «${title}»، لطفاً پروژهٔ تمام ردیف‌های تخصیص را مشخص کنید.`); + return; + } + const qVal = parseFloat(String(a.quantity)); + if (isNaN(qVal) || qVal <= 0) { + toast.error(`برای قلم «${title}»، مقدار تخصیص باید عددی بزرگ‌تر از صفر باشد.`); + return; + } + } + + // الزام ۳: جلوگیری از انتخاب پروژه تکراری برای یک قلم + const pNames = allocs.map((a) => a.project_name.trim()); + if (new Set(pNames).size !== pNames.length) { + toast.error(`برای قلم «${title}»، پروژهٔ تکراری در ردیف‌های تخصیص انتخاب شده است.`); + return; + } + + // الزام ۵: مقایسه با دقت اعشاری بالا + const sumQty = allocs.reduce((sum, a) => sum + (parseFloat(String(a.quantity)) || 0), 0); + const diff = Math.abs(sumQty - targetQty); + if (diff > 0.0001) { + toast.error( + `مجموع مقادیر تخصیص‌یافته برای «${title}» (${fmtNum(sumQty)} ${li.unit}) با مقدار کل قلم (${fmtNum(targetQty)} ${li.unit}) برابر نیست.` + ); + return; + } + } + + // انتساب تخصیص‌های چندپروژه‌ای به اقلام next.line_items.forEach((li) => { if (li.rejected) return; - const name = itemProjectsSel[li.id]; - if (name === undefined) return; - li.target_project = name ? { id: "", name } : null; + const allocs = itemAllocations[li.id]; + if (allocs && allocs.length) { + li.allocations = allocs.map((a) => ({ + project_name: a.project_name.trim(), + quantity: parseFloat(String(a.quantity)) || 0, + })); + li.target_project = { id: "", name: allocs[0].project_name.trim() }; + } else { + const name = itemProjectsSel[li.id]; + if (name !== undefined) { + li.target_project = name ? { id: "", name } : null; + } + } }); const now = new Date().toISOString(); @@ -343,53 +454,173 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { - {/* بخش ۳: تخصیص اقلام به پروژه */} + {/* بخش ۳: تخصیص اقلام به پروژه‌ها */}
-

۳. تخصیص اقلام به پروژه‌ها

+
+

۳. تخصیص اقلام به پروژه‌ها

+
- برای هر متریال خریداری شده، می‌توانید پروژه مربوط به آن را مشخص کنید. + مشخص کنید چه مقداری از هر قلم به کدام پروژه اختصاص داده می‌شود. در صورت خرید یک کالا برای چند پروژه، می‌توانید آن را به چند سطر تفکیک کنید.
-
- {allItems - .filter((li) => !removedItems.has(li.id)) - .map((li) => { - const m = materialById(materials, li.material_id); - return ( -
-
- - {formatMaterialTitle(m)} - - - {fmtNum(li.quantity)} {li.unit} - + + {projectOptions.length === 0 ? ( +
+ ⚠️ هیچ پروژه‌ای در سیستم ثبت نشده است. لطفاً ابتدا از بخش پروژه‌ها، حداقل یک پروژه ایجاد کنید. +
+ ) : ( +
+ {allItems + .filter((li) => !removedItems.has(li.id)) + .map((li) => { + const m = materialById(materials, li.material_id); + const targetQty = Number(li.quantity ?? 0); + const allocs = itemAllocations[li.id] || []; + const allocSum = allocs.reduce( + (sum, a) => sum + (parseFloat(String(a.quantity)) || 0), + 0 + ); + const diff = Math.round((targetQty - allocSum) * 10000) / 10000; + const isComplete = Math.abs(diff) < 0.0001; + const isOver = diff < -0.0001; + + return ( +
+ {/* هدر کارت قلم */} +
+
+ + + {formatMaterialTitle(m)} + +
+ +
+ + کل: {fmtNum(targetQty)} {li.unit} + + + {isComplete ? ( + + + تخصیص کامل ({fmtNum(allocSum)} {li.unit}) + + ) : isOver ? ( + + + مازاد تخصیص: {fmtNum(Math.abs(diff))} {li.unit} + + ) : ( + + + باقیمانده: {fmtNum(diff)} {li.unit} + + )} +
+
+ + {/* ردیف‌های تخصیص */} +
+ {allocs.map((alloc, idx) => { + const otherUsedProjects = new Set( + allocs + .filter((a) => a.rowId !== alloc.rowId) + .map((a) => a.project_name.trim()) + .filter(Boolean) + ); + + return ( +
+ + {faNum(idx + 1)}. + + + {/* انتخاب پروژه (الزام ۳: عدم امکان انتخاب پروژه تکراری) */} +
+ +
+ + {/* مقدار تخصیص و برچسب واحد */} +
+ + updateAllocationRow(li.id, alloc.rowId, { + quantity: e.target.value, + }) + } + /> + + {li.unit} + +
+ + {/* دکمه حذف ردیف */} + +
+ ); + })} +
+ + {/* فوتر کارت قلم: دکمه افزودن ردیف جدید */} +
+ + + مجموع: {fmtNum(allocSum)} از {fmtNum(targetQty)} {li.unit} + +
- {projectOptions.length ? ( - - ) : ( -
پروژه‌ای در سیستم ثبت نشده است.
- )} -
- ); - })} -
+ ); + })} +
+ )}
{/* بخش ۴: فاکتورها، پیش‌فاکتورها و تاریخ */} diff --git a/frontend/src/pages/Dashboard/EditModal.tsx b/frontend/src/pages/Dashboard/EditModal.tsx index 04686b1..3c2ee5f 100644 --- a/frontend/src/pages/Dashboard/EditModal.tsx +++ b/frontend/src/pages/Dashboard/EditModal.tsx @@ -384,7 +384,26 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { li.accepted_ts_id = null; li.target_project = null; li.target_project_id = null; + li.allocations = undefined; }); + + // ادغام سطرهای تفکیک‌شده حاصل از تخصیص چندپروژه‌ای (الزام ۲) + const mergedMap = new Map(); + for (const li of next.line_items) { + const key = `${li.material_id}__${li.unit || ""}__${li.unit_price ?? ""}`; + const existing = mergedMap.get(key); + if (existing) { + const q1 = Number(existing.quantity ?? 0); + const q2 = Number(li.quantity ?? 0); + existing.quantity = Math.round((q1 + q2) * 10000) / 10000; + if (existing.unit_price != null && existing.quantity != null) { + existing.total_price = existing.unit_price * existing.quantity; + } + } else { + mergedMap.set(key, { ...li }); + } + } + next.line_items = Array.from(mergedMap.values()); } else if (next.status === "accepted") { next.arrived_date = null; next.line_items.forEach((li) => { From b326cf64bf0a387a9a97e85bd0ac556f8283758b Mon Sep 17 00:00:00 2001 From: "reza.g" Date: Wed, 23 Sep 2026 22:30:31 +0330 Subject: [PATCH 4/8] feat(inventory): implement inter-project material transfer backend - Add ProjectTransfer database model and migration - Implement project transfer service with immediate stock deduction on creation - Support 100% full delivery receipt logic across destination warehouses - Enforce canonical_specs_hash matching for cross-project stock merge - Add RBAC guards and status lock on received transfers (409 Conflict) - Register API router for transfers with on-the-way integration --- backend/app/db/init_db.py | 45 ++ backend/app/main.py | 2 + backend/app/models/__init__.py | 3 + backend/app/models/project_transfer.py | 87 ++++ backend/app/routers/__init__.py | 2 + backend/app/routers/project_transfers.py | 205 +++++++++ backend/app/schemas/project_transfers.py | 74 ++++ .../app/services/project_transfer_service.py | 417 ++++++++++++++++++ sql/migration.sql | 33 ++ 9 files changed, 868 insertions(+) create mode 100644 backend/app/models/project_transfer.py create mode 100644 backend/app/routers/project_transfers.py create mode 100644 backend/app/schemas/project_transfers.py create mode 100644 backend/app/services/project_transfer_service.py diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py index 018ff34..15cab40 100644 --- a/backend/app/db/init_db.py +++ b/backend/app/db/init_db.py @@ -432,3 +432,48 @@ async def ensure_audit_table(engine: AsyncEngine) -> None: for stmt in indexes: await conn.execute(text(stmt)) logger.info("audit_log table ensured") + + +async def ensure_project_transfers_table(engine: AsyncEngine) -> None: + """Create (if missing) the project_transfers table for inter-site transfers. Safe on any volume.""" + ddl = """ + CREATE TABLE IF NOT EXISTS project_transfers ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + from_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT, + from_warehouse_id UUID NOT NULL REFERENCES warehouses(id) ON DELETE RESTRICT, + to_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT, + material_id UUID NOT NULL REFERENCES materials(id) ON DELETE RESTRICT, + brand_id UUID REFERENCES brands(id) ON DELETE SET NULL, + unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT, + quantity NUMERIC(18,6) NOT NULL CHECK (quantity > 0), + specs JSONB NOT NULL DEFAULT '{}'::jsonb, + specs_hash TEXT NOT NULL DEFAULT 'default', + note TEXT, + status TEXT NOT NULL DEFAULT 'in_transit' CHECK (status IN ('in_transit', 'received', 'cancelled')), + out_transaction_id UUID REFERENCES inventory_transactions(id) ON DELETE SET NULL, + transfer_date TIMESTAMPTZ NOT NULL DEFAULT now(), + transferred_by TEXT, + received_at TIMESTAMPTZ, + received_by TEXT, + to_warehouse_id UUID REFERENCES warehouses(id) ON DELETE SET NULL, + receipt_allocations JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """ + indexes = ( + "CREATE INDEX IF NOT EXISTS idx_project_transfers_from ON project_transfers(from_project_id, status)", + "CREATE INDEX IF NOT EXISTS idx_project_transfers_to ON project_transfers(to_project_id, status)", + "DROP TRIGGER IF EXISTS trg_project_transfers_updated_at ON project_transfers", + """ + CREATE TRIGGER trg_project_transfers_updated_at + BEFORE UPDATE ON project_transfers + FOR EACH ROW EXECUTE FUNCTION set_updated_at() + """, + ) + async with engine.begin() as conn: + await conn.execute(text(ddl)) + for stmt in indexes: + await conn.execute(text(stmt)) + logger.info("project_transfers table ensured") + diff --git a/backend/app/main.py b/backend/app/main.py index adf54bb..42d9f97 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -31,6 +31,7 @@ from app.db.init_db import ( ensure_dashboard_tables, ensure_material_topic_column, ensure_quote_projects, + ensure_project_transfers_table, ensure_v26, ) from app.observability import RequestContextMiddleware, setup_logging @@ -107,6 +108,7 @@ async def lifespan(_app: FastAPI): await ensure_v26(engine) await ensure_audit_table(engine) await ensure_drop_category_tables(engine) + await ensure_project_transfers_table(engine) task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None try: yield diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index ceb57d8..54296b2 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -32,6 +32,8 @@ from app.models.procurement import ( TermsheetScheduleStep, ) +from app.models.project_transfer import ProjectTransfer + __all__ = [ "Base", "Unit", @@ -60,4 +62,5 @@ __all__ = [ "ItemDecision", "ItemReceipt", "QuoteAttachment", + "ProjectTransfer", ] diff --git a/backend/app/models/project_transfer.py b/backend/app/models/project_transfer.py new file mode 100644 index 0000000..cca9779 --- /dev/null +++ b/backend/app/models/project_transfer.py @@ -0,0 +1,87 @@ +import uuid +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import ( + DateTime, + ForeignKey, + Numeric, + Text, + func, + text, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base +from app.models.catalog import _timestamptz, _uuid_pk + + +class ProjectTransfer(Base): + """project_transfers — Inter-project (inter-site) material transfer ledger. + + Records the movement of materials from a source project warehouse to a + destination project. Stock is deducted from the source warehouse immediately + upon creation (status = 'in_transit') via out_transaction_id. + When the destination project PM confirms receipt in 'Arrivals', stock is + added to the destination warehouse (status = 'received'). + While 'in_transit', the transfer can be edited or cancelled (restoring source stock). + Once 'received', the transfer is permanently locked from edit or cancellation. + """ + + __tablename__ = "project_transfers" + __mapper_args__ = {"eager_defaults": True} + + id: Mapped[uuid.UUID] = _uuid_pk() + from_project_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False + ) + from_warehouse_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("warehouses.id", ondelete="RESTRICT"), nullable=False + ) + to_project_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False + ) + material_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("materials.id", ondelete="RESTRICT"), nullable=False + ) + brand_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("brands.id", ondelete="SET NULL") + ) + unit_id: Mapped[int] = mapped_column( + ForeignKey("units.id", ondelete="RESTRICT"), nullable=False + ) + quantity: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False) + specs: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{}'::jsonb")) + specs_hash: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'default'")) + note: Mapped[str | None] = mapped_column(Text) + status: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'in_transit'")) + + out_transaction_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("inventory_transactions.id", ondelete="SET NULL") + ) + transfer_date: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + transferred_by: Mapped[str | None] = mapped_column(Text) + received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + received_by: Mapped[str | None] = mapped_column(Text) + to_warehouse_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("warehouses.id", ondelete="SET NULL") + ) + receipt_allocations: Mapped[list | None] = mapped_column(JSONB) + created_at: Mapped[datetime] = _timestamptz() + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) + + from_project: Mapped["Project"] = relationship("Project", foreign_keys=[from_project_id], lazy="raise") + to_project: Mapped["Project"] = relationship("Project", foreign_keys=[to_project_id], lazy="raise") + from_warehouse: Mapped["Warehouse"] = relationship("Warehouse", foreign_keys=[from_warehouse_id], lazy="raise") + to_warehouse: Mapped["Warehouse | None"] = relationship("Warehouse", foreign_keys=[to_warehouse_id], lazy="raise") + material: Mapped["Material"] = relationship("Material", foreign_keys=[material_id], lazy="raise") + brand: Mapped["Brand | None"] = relationship("Brand", foreign_keys=[brand_id], lazy="raise") + unit: Mapped["Unit"] = relationship("Unit", foreign_keys=[unit_id], lazy="raise") + out_transaction: Mapped["InventoryTransaction | None"] = relationship( + "InventoryTransaction", foreign_keys=[out_transaction_id], lazy="raise" + ) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py index edbe584..3b5c631 100644 --- a/backend/app/routers/__init__.py +++ b/backend/app/routers/__init__.py @@ -14,6 +14,7 @@ from app.routers import ( material_categories, materials, pm, + project_transfers, projects, quote_events, quotes, @@ -50,5 +51,6 @@ ROUTERS = [ standard_conversions.router, inventory.router, pm.router, + project_transfers.router, users.router, ] diff --git a/backend/app/routers/project_transfers.py b/backend/app/routers/project_transfers.py new file mode 100644 index 0000000..e9f9e83 --- /dev/null +++ b/backend/app/routers/project_transfers.py @@ -0,0 +1,205 @@ +import uuid +from typing import Literal + +from fastapi import APIRouter, Depends, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.engine import get_db +from app.routers.pm import ArrivalScope, arrivals_scope +from app.schemas.project_transfers import ( + ProjectTransferCreate, + ProjectTransferOut, + ProjectTransferReceive, + ProjectTransferUpdate, +) +from app.services import project_transfer_service +from app.utils.errors import ForbiddenError + +router = APIRouter(tags=["project-transfers"]) + + +def _to_transfer_out(t) -> dict: + return { + "id": t.id, + "from_project": ( + {"id": t.from_project.id, "name": t.from_project.name} + if t.from_project + else {"id": t.from_project_id, "name": ""} + ), + "from_warehouse": ( + {"id": t.from_warehouse.id, "name": t.from_warehouse.name} + if t.from_warehouse + else {"id": t.from_warehouse_id, "name": ""} + ), + "to_project": ( + {"id": t.to_project.id, "name": t.to_project.name} + if t.to_project + else {"id": t.to_project_id, "name": ""} + ), + "to_warehouse": ( + {"id": t.to_warehouse.id, "name": t.to_warehouse.name} + if t.to_warehouse + else None + ), + "material": ( + {"id": t.material.id, "name": t.material.name} + if t.material + else {"id": t.material_id, "name": ""} + ), + "brand": ( + {"id": t.brand.id, "name": t.brand.name} + if t.brand + else None + ), + "unit": ( + {"id": t.unit.id, "code": t.unit.code, "name_fa": t.unit.name_fa} + if t.unit + else {"id": t.unit_id, "code": "", "name_fa": None} + ), + "quantity": t.quantity, + "specs": t.specs or {}, + "specs_hash": t.specs_hash, + "note": t.note, + "status": t.status, + "transfer_date": t.transfer_date, + "transferred_by": t.transferred_by, + "received_at": t.received_at, + "received_by": t.received_by, + "receipt_allocations": t.receipt_allocations, + "created_at": t.created_at, + "updated_at": t.updated_at, + } + + +# --------------------------------------------------------------------------- +# POST /inventory/project-transfers — Create inter-project transfer +# --------------------------------------------------------------------------- +@router.post("/inventory/project-transfers", status_code=201, response_model=ProjectTransferOut) +async def create_transfer( + payload: ProjectTransferCreate, + ctx: ArrivalScope = Depends(arrivals_scope), + db: AsyncSession = Depends(get_db), +): + transfer = await project_transfer_service.create_project_transfer( + db=db, + payload=payload, + caller_project_id=ctx.project_id, + caller_role=ctx.role, + transferred_by=ctx.display_name, + ) + return _to_transfer_out(transfer) + + +# --------------------------------------------------------------------------- +# GET /inventory/project-transfers — List transfers with direction and filters +# --------------------------------------------------------------------------- +@router.get("/inventory/project-transfers") +async def list_transfers( + project_id: uuid.UUID | None = Query(default=None), + direction: Literal["outgoing", "incoming", "all"] = Query(default="all"), + status: str | None = Query(default=None), + ctx: ArrivalScope = Depends(arrivals_scope), + db: AsyncSession = Depends(get_db), +): + target_pid = project_id or ctx.project_id + transfers = await project_transfer_service.list_project_transfers( + db=db, + project_id=target_pid, + direction=direction, + status=status, + ) + return [_to_transfer_out(t) for t in transfers] + + +# --------------------------------------------------------------------------- +# GET /inventory/project-transfers/{id} — Single transfer details +# --------------------------------------------------------------------------- +@router.get("/inventory/project-transfers/{transfer_id}", response_model=ProjectTransferOut) +async def get_transfer( + transfer_id: uuid.UUID, + ctx: ArrivalScope = Depends(arrivals_scope), + db: AsyncSession = Depends(get_db), +): + transfer = await project_transfer_service.get_project_transfer_or_404(db, transfer_id) + if ctx.role == "pm" and ctx.project_id is not None: + if transfer.from_project_id != ctx.project_id and transfer.to_project_id != ctx.project_id: + raise ForbiddenError("شما به این رکورد انتقال دسترسی ندارید") + return _to_transfer_out(transfer) + + +# --------------------------------------------------------------------------- +# PATCH /inventory/project-transfers/{id} — Edit in-transit transfer (source PM only) +# --------------------------------------------------------------------------- +@router.patch("/inventory/project-transfers/{transfer_id}", response_model=ProjectTransferOut) +async def update_transfer( + transfer_id: uuid.UUID, + payload: ProjectTransferUpdate, + ctx: ArrivalScope = Depends(arrivals_scope), + db: AsyncSession = Depends(get_db), +): + transfer = await project_transfer_service.update_project_transfer( + db=db, + transfer_id=transfer_id, + payload=payload, + caller_project_id=ctx.project_id, + caller_role=ctx.role, + ) + return _to_transfer_out(transfer) + + +# --------------------------------------------------------------------------- +# DELETE /inventory/project-transfers/{id} — Cancel in-transit transfer (source PM only) +# --------------------------------------------------------------------------- +@router.delete("/inventory/project-transfers/{transfer_id}") +async def delete_transfer( + transfer_id: uuid.UUID, + ctx: ArrivalScope = Depends(arrivals_scope), + db: AsyncSession = Depends(get_db), +): + return await project_transfer_service.delete_project_transfer( + db=db, + transfer_id=transfer_id, + caller_project_id=ctx.project_id, + caller_role=ctx.role, + ) + + +# --------------------------------------------------------------------------- +# GET /pm/transfers/on-the-way — Incoming in-transit transfers for active project +# --------------------------------------------------------------------------- +@router.get("/pm/transfers/on-the-way") +async def pm_incoming_transfers( + ctx: ArrivalScope = Depends(arrivals_scope), + db: AsyncSession = Depends(get_db), +): + if ctx.role == "pm" and ctx.project_id is None: + raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید") + + transfers = await project_transfer_service.list_project_transfers( + db=db, + project_id=ctx.project_id, + direction="incoming", + status="in_transit", + ) + return [_to_transfer_out(t) for t in transfers] + + +# --------------------------------------------------------------------------- +# POST /inventory/project-transfers/{id}/receive — Destination PM confirms arrival +# --------------------------------------------------------------------------- +@router.post("/inventory/project-transfers/{transfer_id}/receive", response_model=ProjectTransferOut) +async def receive_transfer( + transfer_id: uuid.UUID, + payload: ProjectTransferReceive, + ctx: ArrivalScope = Depends(arrivals_scope), + db: AsyncSession = Depends(get_db), +): + transfer = await project_transfer_service.receive_project_transfer( + db=db, + transfer_id=transfer_id, + payload=payload, + caller_project_id=ctx.project_id, + caller_role=ctx.role, + received_by=ctx.display_name, + ) + return _to_transfer_out(transfer) diff --git a/backend/app/schemas/project_transfers.py b/backend/app/schemas/project_transfers.py new file mode 100644 index 0000000..ac45b95 --- /dev/null +++ b/backend/app/schemas/project_transfers.py @@ -0,0 +1,74 @@ +from datetime import datetime +from decimal import Decimal +from uuid import UUID + +from pydantic import Field + +from app.schemas.common import APIModel + + +class WarehouseAllocationIn(APIModel): + warehouse_id: UUID + quantity: Decimal = Field(gt=0) + + +class ProjectTransferCreate(APIModel): + from_warehouse_id: UUID + to_project_id: UUID + material_id: UUID + brand_id: UUID | None = None + unit_id: int + quantity: Decimal = Field(gt=0) + specs: dict = Field(default_factory=dict) + note: str | None = None + transfer_date: datetime | None = None + + +class ProjectTransferUpdate(APIModel): + to_project_id: UUID | None = None + quantity: Decimal | None = Field(default=None, gt=0) + note: str | None = None + transfer_date: datetime | None = None + + +class ProjectTransferReceive(APIModel): + allocations: list[WarehouseAllocationIn] = Field( + min_length=1, + description="Warehouse allocations in destination project (sum must equal transfer quantity)", + ) + received_at: datetime | None = None + note: str | None = None + + +class IdNameOut(APIModel): + id: UUID + name: str + + +class UnitBriefOut(APIModel): + id: int + code: str + name_fa: str | None = None + + +class ProjectTransferOut(APIModel): + id: UUID + from_project: IdNameOut + from_warehouse: IdNameOut + to_project: IdNameOut + to_warehouse: IdNameOut | None = None + material: IdNameOut + brand: IdNameOut | None = None + unit: UnitBriefOut + quantity: Decimal + specs: dict + specs_hash: str + note: str | None = None + status: str + transfer_date: datetime + transferred_by: str | None = None + received_at: datetime | None = None + received_by: str | None = None + receipt_allocations: list[dict] | None = None + created_at: datetime + updated_at: datetime diff --git a/backend/app/services/project_transfer_service.py b/backend/app/services/project_transfer_service.py new file mode 100644 index 0000000..1bdf617 --- /dev/null +++ b/backend/app/services/project_transfer_service.py @@ -0,0 +1,417 @@ +import uuid +from datetime import datetime, timezone +from decimal import Decimal + +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.catalog import Brand, Material, Unit +from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse +from app.models.procurement import Project +from app.models.project_transfer import ProjectTransfer +from app.schemas.project_transfers import ( + ProjectTransferCreate, + ProjectTransferReceive, + ProjectTransferUpdate, +) +from app.services.inventory_service import ( + _find_inventory, + _get_or_create_inventory, + canonical_specs_hash, +) +from app.utils.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError + + +async def get_project_transfer_or_404(db: AsyncSession, transfer_id: uuid.UUID) -> ProjectTransfer: + stmt = ( + select(ProjectTransfer) + .where(ProjectTransfer.id == transfer_id) + .options( + selectinload(ProjectTransfer.from_project), + selectinload(ProjectTransfer.to_project), + selectinload(ProjectTransfer.from_warehouse), + selectinload(ProjectTransfer.to_warehouse), + selectinload(ProjectTransfer.material), + selectinload(ProjectTransfer.brand), + selectinload(ProjectTransfer.unit), + ) + ) + transfer = await db.scalar(stmt) + if transfer is None: + raise NotFoundError("رکورد انتقال بین کارگاهی یافت نشد", field="transfer_id") + return transfer + + +async def create_project_transfer( + db: AsyncSession, + payload: ProjectTransferCreate, + caller_project_id: uuid.UUID | None, + caller_role: str, + transferred_by: str | None = None, +) -> ProjectTransfer: + tx = db.begin_nested() if db.in_transaction() else db.begin() + async with tx: + # Validate source warehouse + from_wh = await db.get(Warehouse, payload.from_warehouse_id) + if from_wh is None: + raise NotFoundError("انبار مبدأ یافت نشد", field="from_warehouse_id") + + # RBAC: PM can only send from their own project + if caller_role == "pm" and caller_project_id is not None: + if from_wh.project_id != caller_project_id: + raise ForbiddenError("شما فقط مجاز به انتقال از انبار پروژه خود هستید") + + from_proj = await db.get(Project, from_wh.project_id) + to_proj = await db.get(Project, payload.to_project_id) + if to_proj is None: + raise NotFoundError("پروژه مقصد یافت نشد", field="to_project_id") + + if from_wh.project_id == payload.to_project_id: + raise ValidationError( + "پروژه مقصد نمی‌تواند همان پروژه مبدأ باشد (از انتقال بین‌انباری استفاده کنید)", + field="to_project_id", + ) + + # Validate material, brand, unit + mat = await db.get(Material, payload.material_id) + if mat is None: + raise NotFoundError("ماده اولیه یافت نشد", field="material_id") + + if payload.brand_id is not None: + brand = await db.get(Brand, payload.brand_id) + if brand is None: + raise NotFoundError("برند یافت نشد", field="brand_id") + + unit = await db.get(Unit, payload.unit_id) + if unit is None: + raise NotFoundError("واحد یافت نشد", field="unit_id") + + # Requirement 2: Canonical specs hash from inventory_service + specs = payload.specs or {} + specs_hash = canonical_specs_hash(specs) + + # Check stock in source warehouse with SELECT ... FOR UPDATE + source_item = await _find_inventory( + db, + payload.from_warehouse_id, + payload.material_id, + payload.brand_id, + specs_hash, + for_update=True, + ) + if source_item is None or source_item.quantity < payload.quantity: + avail = source_item.quantity if source_item else Decimal("0") + raise ValidationError( + f"موجودی کافی در انبار مبدأ وجود ندارد (موجودی فعلی: {avail})", + field="quantity", + ) + + # Deduct from source warehouse immediately + source_item.quantity -= payload.quantity + + transfer_pair_id = uuid.uuid4() + now_dt = payload.transfer_date or datetime.now(timezone.utc) + note_text = f"انتقال بین کارگاهی به پروژه {to_proj.name}" + if payload.note: + note_text += f" — {payload.note}" + + out_tx = InventoryTransaction( + inventory_id=source_item.id, + warehouse_id=from_wh.id, + transaction_type="TRANSFER_OUT", + quantity=-payload.quantity, + transfer_pair_id=transfer_pair_id, + performed_by=transferred_by, + transaction_date=now_dt, + note=note_text, + ) + db.add(out_tx) + await db.flush() + + 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, + quantity=payload.quantity, + specs=specs, + specs_hash=specs_hash, + note=payload.note, + status="in_transit", + out_transaction_id=out_tx.id, + transfer_date=now_dt, + transferred_by=transferred_by, + ) + db.add(transfer) + await db.flush() + + return await get_project_transfer_or_404(db, transfer.id) + + +async def update_project_transfer( + db: AsyncSession, + transfer_id: uuid.UUID, + payload: ProjectTransferUpdate, + caller_project_id: uuid.UUID | None, + caller_role: str, +) -> ProjectTransfer: + tx = db.begin_nested() if db.in_transaction() else db.begin() + async with tx: + transfer = await db.scalar( + select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update() + ) + if transfer is None: + raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id") + + # RBAC (Requirement 4) + if caller_role == "pm" and caller_project_id is not None: + if transfer.from_project_id != caller_project_id: + raise ForbiddenError("شما فقط مجاز به ویرایش انتقال‌های پروژه مبدأ خود هستید") + + # Status Lock Guard (Requirement 4) + if transfer.status == "received": + raise ConflictError( + "این انتقال در پروژه مقصد تحویل و ثبت انبار شده است و امکان ویرایش آن وجود ندارد" + ) + if transfer.status != "in_transit": + raise ConflictError("فقط انتقال‌های در راه (in_transit) قابل ویرایش هستند") + + if payload.to_project_id is not None and payload.to_project_id != transfer.to_project_id: + new_to_proj = await db.get(Project, payload.to_project_id) + if new_to_proj is None: + raise NotFoundError("پروژه مقصد یافت نشد", field="to_project_id") + if payload.to_project_id == transfer.from_project_id: + raise ValidationError("پروژه مقصد نمی‌تواند همان پروژه مبدأ باشد", field="to_project_id") + transfer.to_project_id = payload.to_project_id + + # Quantity adjustment (reconciles source stock difference) + if payload.quantity is not None and payload.quantity != transfer.quantity: + delta = payload.quantity - transfer.quantity + source_item = await _find_inventory( + db, + transfer.from_warehouse_id, + transfer.material_id, + transfer.brand_id, + transfer.specs_hash, + for_update=True, + ) + if delta > 0: + if source_item is None or source_item.quantity < delta: + avail = source_item.quantity if source_item else Decimal("0") + raise ValidationError( + f"موجودی انبار مبدأ برای افزایش انتقال کافی نیست (موجود: {avail})", + field="quantity", + ) + source_item.quantity -= delta + else: + if source_item is not None: + source_item.quantity += abs(delta) + + transfer.quantity = payload.quantity + + # Update out_transaction + if transfer.out_transaction_id is not None: + out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id) + if out_tx is not None: + out_tx.quantity = -payload.quantity + + if payload.note is not None: + transfer.note = payload.note + + if payload.transfer_date is not None: + transfer.transfer_date = payload.transfer_date + if transfer.out_transaction_id is not None: + out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id) + if out_tx is not None: + out_tx.transaction_date = payload.transfer_date + + return await get_project_transfer_or_404(db, transfer_id) + + +async def delete_project_transfer( + db: AsyncSession, + transfer_id: uuid.UUID, + caller_project_id: uuid.UUID | None, + caller_role: str, +) -> dict: + tx = db.begin_nested() if db.in_transaction() else db.begin() + async with tx: + transfer = await db.scalar( + select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update() + ) + if transfer is None: + raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id") + + # RBAC (Requirement 4) + if caller_role == "pm" and caller_project_id is not None: + if transfer.from_project_id != caller_project_id: + raise ForbiddenError("شما فقط مجاز به لغو انتقال‌های پروژه مبدأ خود هستید") + + # Status Lock Guard (Requirement 4) + if transfer.status == "received": + raise ConflictError( + "این انتقال در پروژه مقصد تحویل و ثبت انبار شده است و امکان لغو یا حذف آن وجود ندارد" + ) + + # Reversibility (Requirement 4): Restore stock to source warehouse + source_item = await _find_inventory( + db, + transfer.from_warehouse_id, + transfer.material_id, + transfer.brand_id, + transfer.specs_hash, + for_update=True, + ) + if source_item is not None: + source_item.quantity += transfer.quantity + + # Delete out_transaction + if transfer.out_transaction_id is not None: + out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id) + if out_tx is not None: + await db.delete(out_tx) + + await db.delete(transfer) + + return {"ok": True, "message": "انتقال با موفقیت لغو شد و موجودی به انبار مبدأ بازگردانده شد"} + + +async def receive_project_transfer( + db: AsyncSession, + transfer_id: uuid.UUID, + payload: ProjectTransferReceive, + caller_project_id: uuid.UUID | None, + caller_role: str, + received_by: str | None = None, +) -> ProjectTransfer: + tx = db.begin_nested() if db.in_transaction() else db.begin() + async with tx: + transfer = await db.scalar( + select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update() + ) + if transfer is None: + raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id") + + # RBAC (Requirement 4) + if caller_role == "pm" and caller_project_id is not None: + if transfer.to_project_id != caller_project_id: + raise ForbiddenError("شما فقط مجاز به تأیید تحویل در پروژه مقصد خود هستید") + + # Lock Guard + if transfer.status == "received": + raise ConflictError("این انتقال قبلاً تحویل گرفته و ثبت انبار شده است") + if transfer.status != "in_transit": + raise ConflictError("این انتقال در وضعیت در راه نیست") + + # Requirement 1: 100% full delivery logic (no partial receipt) + alloc_sum = sum(Decimal(str(a.quantity)) for a in payload.allocations) + if alloc_sum != transfer.quantity: + raise ValidationError( + f"تخصیص انبارها باید دقیقاً برابر کل مقدار ارسالی ({transfer.quantity}) باشد. مجموع واردشده: {alloc_sum}", + field="allocations", + ) + + # Validate destination warehouses belong to destination project + wh_ids = [a.warehouse_id for a in payload.allocations] + wh_rows = (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars().all() + wh_map = {w.id: w for w in wh_rows} + + for a in payload.allocations: + wh = wh_map.get(a.warehouse_id) + if wh is None or wh.project_id != transfer.to_project_id: + raise ValidationError( + "انبار انتخاب‌شده معتبر نیست یا متعلق به پروژه مقصد نمی‌باشد", + field="allocations.warehouse_id", + ) + + from_proj = await db.get(Project, transfer.from_project_id) + from_proj_name = from_proj.name if from_proj else "پروژه مبدأ" + + out_tx = ( + await db.get(InventoryTransaction, transfer.out_transaction_id) + if transfer.out_transaction_id + else None + ) + pair_id = out_tx.transfer_pair_id if (out_tx and out_tx.transfer_pair_id) else uuid.uuid4() + now_dt = payload.received_at or datetime.now(timezone.utc) + + # Add stock to destination warehouse(s) via TRANSFER_IN + for a in payload.allocations: + dest_item = await _get_or_create_inventory( + db, + a.warehouse_id, + transfer.material_id, + transfer.brand_id, + transfer.unit_id, + transfer.specs, + transfer.specs_hash, + ) + dest_item.quantity += a.quantity + + note_text = f"رسید انتقال بین کارگاهی از {from_proj_name}" + if payload.note: + note_text += f" — {payload.note}" + + in_tx = InventoryTransaction( + inventory_id=dest_item.id, + warehouse_id=a.warehouse_id, + transaction_type="TRANSFER_IN", + quantity=a.quantity, + transfer_pair_id=pair_id, + performed_by=received_by, + transaction_date=now_dt, + note=note_text, + ) + db.add(in_tx) + + transfer.status = "received" + transfer.received_at = now_dt + transfer.received_by = received_by + transfer.to_warehouse_id = payload.allocations[0].warehouse_id + transfer.receipt_allocations = [ + {"warehouse_id": str(a.warehouse_id), "quantity": str(a.quantity)} + for a in payload.allocations + ] + + return await get_project_transfer_or_404(db, transfer_id) + + +async def list_project_transfers( + db: AsyncSession, + project_id: uuid.UUID | None = None, + direction: str = "all", # 'outgoing' | 'incoming' | 'all' + status: str | None = None, +) -> list[ProjectTransfer]: + stmt = ( + select(ProjectTransfer) + .options( + selectinload(ProjectTransfer.from_project), + selectinload(ProjectTransfer.to_project), + selectinload(ProjectTransfer.from_warehouse), + selectinload(ProjectTransfer.to_warehouse), + selectinload(ProjectTransfer.material), + selectinload(ProjectTransfer.brand), + selectinload(ProjectTransfer.unit), + ) + ) + + if project_id is not None: + if direction == "outgoing": + stmt = stmt.where(ProjectTransfer.from_project_id == project_id) + elif direction == "incoming": + stmt = stmt.where(ProjectTransfer.to_project_id == project_id) + else: + stmt = stmt.where( + (ProjectTransfer.from_project_id == project_id) + | (ProjectTransfer.to_project_id == project_id) + ) + + if status: + stmt = stmt.where(ProjectTransfer.status == status) + + stmt = stmt.order_by(ProjectTransfer.transfer_date.desc()) + res = await db.execute(stmt) + return list(res.scalars().all()) diff --git a/sql/migration.sql b/sql/migration.sql index f842c5d..526decd 100644 --- a/sql/migration.sql +++ b/sql/migration.sql @@ -551,6 +551,35 @@ CREATE TABLE standard_conversions ( CONSTRAINT uq_standard_conversion UNIQUE (from_unit, to_unit) ); +-- Inter-project material transfers +CREATE TABLE project_transfers ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + from_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT, + from_warehouse_id UUID NOT NULL REFERENCES warehouses(id) ON DELETE RESTRICT, + to_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT, + material_id UUID NOT NULL REFERENCES materials(id) ON DELETE RESTRICT, + brand_id UUID REFERENCES brands(id) ON DELETE SET NULL, + unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT, + quantity NUMERIC(18,6) NOT NULL CHECK (quantity > 0), + specs JSONB NOT NULL DEFAULT '{}'::jsonb, + specs_hash TEXT NOT NULL DEFAULT 'default', + note TEXT, + status TEXT NOT NULL DEFAULT 'in_transit' CHECK (status IN ('in_transit', 'received', 'cancelled')), + out_transaction_id UUID REFERENCES inventory_transactions(id) ON DELETE SET NULL, + transfer_date TIMESTAMPTZ NOT NULL DEFAULT now(), + transferred_by TEXT, + received_at TIMESTAMPTZ, + received_by TEXT, + to_warehouse_id UUID REFERENCES warehouses(id) ON DELETE SET NULL, + receipt_allocations JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_project_transfers_from ON project_transfers(from_project_id, status); +CREATE INDEX idx_project_transfers_to ON project_transfers(to_project_id, status); + + -- ============================================================================ -- UPDATED_AT TRIGGER (auto-set updated_at on row modification) -- ============================================================================ @@ -579,6 +608,10 @@ CREATE TRIGGER trg_quote_attachments_updated_at BEFORE UPDATE ON quote_attachments FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +CREATE TRIGGER trg_project_transfers_updated_at + BEFORE UPDATE ON project_transfers + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + -- ============================================================================ -- AUTOMATION TRIGGERS (v2.5 + v2.6) -- ============================================================================ From 856513b6d75c572a4d059db48ff30cc01011a8b5 Mon Sep 17 00:00:00 2001 From: "reza.g" Date: Wed, 23 Sep 2026 22:31:10 +0330 Subject: [PATCH 5/8] feat(frontend): integrate inter-project material transfer into ChangeInventoryPage and ArrivalsPage - Add PROJECT_TRANSFER tab under reductions in ChangeInventoryPage - Render complete material spec form with destination project selector - Show matching stock preview in source warehouse - Add transfer history table with in-transit edit modal and cancellation dialog - Add incoming transfer cards in ArrivalsPage with 100% full delivery allocation --- frontend/src/lib/api.ts | 28 ++ frontend/src/lib/types.ts | 49 ++ frontend/src/pages/Inventory/ArrivalsPage.tsx | 225 ++++++++- .../pages/Inventory/ChangeInventoryPage.tsx | 461 +++++++++++++++++- 4 files changed, 742 insertions(+), 21 deletions(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 365a067..fc08b31 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -30,6 +30,10 @@ import type { Supplier, TransactionType, Warehouse, + ProjectTransfer, + ProjectTransferCreateIn, + ProjectTransferUpdateIn, + ProjectTransferReceiveIn, } from "./types"; const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1"; @@ -1746,4 +1750,28 @@ export const apiV26 = { attributes: [], }), }, + + projectTransfers: { + list: async (projectId?: string | null, direction: "outgoing" | "incoming" | "all" = "all", status?: string): Promise => { + const q = new URLSearchParams(); + if (projectId) q.set("project_id", projectId); + if (direction) q.set("direction", direction); + if (status) q.set("status", status); + const qs = q.toString() ? `?${q.toString()}` : ""; + return getData(`/inventory/project-transfers${qs}`); + }, + get: async (id: string, projectId?: string | null): Promise => + getData(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`), + create: async (body: ProjectTransferCreateIn, projectId?: string | null): Promise => + postData(projectId ? `/inventory/project-transfers?project_id=${projectId}` : `/inventory/project-transfers`, body), + update: async (id: string, patch: ProjectTransferUpdateIn, projectId?: string | null): Promise => + patchData(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`, patch), + delete: async (id: string, projectId?: string | null): Promise<{ ok: boolean; message: string }> => + deleteData(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`), + onTheWay: async (projectId?: string | null): Promise => + getData(projectId ? `/pm/transfers/on-the-way?project_id=${projectId}` : `/pm/transfers/on-the-way`), + receive: async (id: string, body: ProjectTransferReceiveIn, projectId?: string | null): Promise => + postData(projectId ? `/inventory/project-transfers/${id}/receive?project_id=${projectId}` : `/inventory/project-transfers/${id}/receive`, body), + }, }; + diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index e026a84..28fb14c 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -200,3 +200,52 @@ export interface ConfirmedReceipt { quality_note: string | null; allocations: { warehouse_id: string; warehouse_name: string; quantity: string }[]; } + +export interface ProjectTransfer { + id: string; + from_project: { id: string; name: string }; + from_warehouse: { id: string; name: string }; + to_project: { id: string; name: string }; + to_warehouse?: { id: string; name: string } | null; + material: { id: string; name: string }; + brand?: { id: string; name: string } | null; + unit: { id: number; code: string; name_fa?: string | null }; + quantity: number; + specs: Record; + specs_hash: string; + note?: string | null; + status: "in_transit" | "received" | "cancelled"; + transfer_date: string; + transferred_by?: string | null; + received_at?: string | null; + received_by?: string | null; + receipt_allocations?: { warehouse_id: string; quantity: string | number }[] | null; + created_at: string; + updated_at: string; +} + +export interface ProjectTransferCreateIn { + from_warehouse_id: string; + to_project_id: string; + material_id: string; + brand_id?: string | null; + unit_id: number; + quantity: number; + specs?: Record; + note?: string | null; + transfer_date?: string | null; +} + +export interface ProjectTransferUpdateIn { + to_project_id?: string; + quantity?: number; + note?: string | null; + transfer_date?: string | null; +} + +export interface ProjectTransferReceiveIn { + allocations: { warehouse_id: string; quantity: number }[]; + received_at?: string | null; + note?: string | null; +} + diff --git a/frontend/src/pages/Inventory/ArrivalsPage.tsx b/frontend/src/pages/Inventory/ArrivalsPage.tsx index bddd4bb..8b4a722 100644 --- a/frontend/src/pages/Inventory/ArrivalsPage.tsx +++ b/frontend/src/pages/Inventory/ArrivalsPage.tsx @@ -8,7 +8,7 @@ import { useAuthStore } from "../../store/auth"; import { inventorySubheader } from "./panelMenu"; import { apiV26 } from "../../lib/api"; import { refreshProcurementStore } from "../../hooks"; -import type { PendingArrival, Warehouse } from "../../lib/types"; +import type { PendingArrival, Warehouse, ProjectTransfer } from "../../lib/types"; import { faNum, fmtDate } from "../../lib/formatters"; // کالاهای در راه — داشبورد متصل به سیستم تدارکات (مرحله رسید) @@ -25,22 +25,27 @@ export function ArrivalsPage() { const isPM = user?.role === "pm"; const [items, setItems] = useState([]); + const [incomingTransfers, setIncomingTransfers] = useState([]); const [warehouses, setWarehouses] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(null); // quoteId + const [transferSaving, setTransferSaving] = useState(null); // transferId const [expanded, setExpanded] = useState>(new Set()); const [drafts, setDrafts] = useState>({}); + const [transferDrafts, setTransferDrafts] = useState>({}); const load = useCallback(async () => { try { const activeProject = isPM ? user?.project_id || undefined : undefined; - const [arr, whs] = await Promise.all([ + const [arr, whs, incoming] = await Promise.all([ // پروژه‌ی فعال صریحاً ارسال می‌شود؛ بک‌اند تخصیص PM را بررسی می‌کند apiV26.pm.onTheWay(activeProject), apiV26.warehouses.list(activeProject), + apiV26.projectTransfers.onTheWay(activeProject), ]); setItems(arr); setWarehouses(whs); + setIncomingTransfers(incoming); } catch (err) { toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه"); } finally { @@ -212,6 +217,53 @@ export function ArrivalsPage() { } }; + // ---------- transfer helpers ---------- + + const transferDraftOf = (t: ProjectTransfer): AllocRow[] => { + if (transferDrafts[t.id] && transferDrafts[t.id].length > 0) { + return transferDrafts[t.id]; + } + const defaultWh = warehouses.find((w) => String(w.project_id) === String(t.to_project.id))?.id || warehouses[0]?.id || ""; + return [{ key: 1, qty: String(t.quantity), whId: defaultWh }]; + }; + + const setTransferAlloc = (transferId: string, rows: AllocRow[]) => { + setTransferDrafts((prev) => ({ ...prev, [transferId]: rows })); + }; + + const handleReceiveTransfer = async (transfer: ProjectTransfer, allocs: AllocRow[]) => { + const activeProject = isPM ? user?.project_id || undefined : undefined; + const allocSum = round4(allocs.reduce((s, a) => s + (parseFloat(a.qty) || 0), 0)); + const targetQty = parseFloat(String(transfer.quantity)); + + if (allocSum !== targetQty) { + toast.error(`مجموع تخصیص انبارها (${faNum(allocSum)}) باید دقیقاً برابر کل بار ارسالی (${faNum(targetQty)}) باشد.`); + return; + } + if (allocs.some((a) => (parseFloat(a.qty) || 0) > 0 && !a.whId)) { + toast.error("لطفاً برای تمامی ردیف‌های دارای مقدار، انبار مقصد را انتخاب کنید."); + return; + } + + setTransferSaving(transfer.id); + try { + const payloadAllocs = allocs + .filter((a) => (parseFloat(a.qty) || 0) > 0 && a.whId) + .map((a) => ({ warehouse_id: a.whId, quantity: parseFloat(a.qty) })); + + await apiV26.projectTransfers.receive(transfer.id, { + allocations: payloadAllocs, + }, activeProject); + + toast.success("تحویل انتقال بین کارگاهی ۱۰۰٪ تأیید شد و به انبار پروژه وارد گردید ✓"); + await load(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "خطا در تأیید تحویل انتقال"); + } finally { + setTransferSaving(null); + } + }; + if (!isAdmin && !isPM) { return (
@@ -233,19 +285,171 @@ export function ArrivalsPage() { />
- 🚚 این بخش به مرحله «رسید» سفارش‌های خرید متصل است: تأیید پیشنهاد و پذیرش در سیستم تدارکات انجام شده و - اینجا فقط رسید و تقسیم بین انبارها ثبت می‌شود. رسید جزیی (قلمی و مقداری) مجاز است — هر سفارش را باز - کنید، تعداد واقعیِ رسیده هر قلم را وارد کنید و آن را بین انبارهای پروژه تقسیم کنید؛ قلم‌هایی که مقداری برایشان - وارد نشود در صف «در راه» می‌مانند. مجموع تخصیص هر قلم باید دقیقاً با تعداد رسیده‌ی همان قلم برابر باشد و از - مانده‌ی سفارش بیشتر نشود. + 🚚 این بخش به مرحله «رسید» سفارش‌های خرید و «انتقال بین کارگاهی» متصل است: اقلام ارسالی از سایر پروژه‌ها به صورت ۱۰۰٪ تحویل و بین انبارهای پروژه تخصیص داده می‌شوند؛ سفارش‌های خرید نیز قابلیت رسید کامل یا جزیی دارند.
{loading ? (
در حال بارگذاری…
- ) : !orders.length ? ( + ) : !orders.length && !incomingTransfers.length ? ( ) : ( - orders.map(([quoteId, its]) => { + <> + {/* بخش انتقال‌های بین کارگاهی در راه */} + {incomingTransfers.length > 0 && ( +
+
+

🔁 انتقال‌های بین کارگاهی در راه ({faNum(incomingTransfers.length)})

+ — تحویل ۱۰۰٪ کامل مصالح ارسالی از سایر پروژه‌ها +
+ + {incomingTransfers.map((t) => { + const draftAllocs = transferDraftOf(t); + const allocSum = round4(draftAllocs.reduce((s, a) => s + (parseFloat(a.qty) || 0), 0)); + const targetQty = parseFloat(String(t.quantity)); + const isExactMatch = allocSum === targetQty; + const hasEmptyWh = draftAllocs.some((a) => (parseFloat(a.qty) || 0) > 0 && !a.whId); + const isValid = isExactMatch && !hasEmptyWh && allocSum > 0; + const days = daysElapsed(t.transfer_date); + const projectWhs = warehouses.filter((w) => String(w.project_id) === String(t.to_project.id)); + const destWhOptions = projectWhs.length > 0 ? projectWhs : warehouses; + + return ( +
+ {/* سربرگ انتقال */} +
+
+ 🔁 انتقال از {t.from_project?.name || "پروژه مبدأ"} + در راه + {days !== null && ( + = 7 ? "offered" : "muted"}> + روز گذشته: {faNum(days)} روز + + )} +
+
+ ارسال‌کننده: {t.transferred_by || "—"} + تاریخ ارسال: {fmtDate(t.transfer_date)} + پروژه مقصد: {t.to_project?.name || "—"} +
+
+ +
+
+ {t.material?.name} + {t.brand?.name && {t.brand.name}} + + مقدار ارسالی: {faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code || ""} + + {t.specs && Object.keys(t.specs).length > 0 && ( + + مشخصات: {Object.entries(t.specs).map(([k, v]) => `${k}: ${v}`).join(" · ")} + + )} +
+ + {t.note && ( +
+ یادداشت ارسال: {t.note} +
+ )} + +
+
+ تخصیص ۱۰۰٪ به انبار(های) پروژه (مجموع باید دقیقاً {faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code || ""} باشد): +
+ +
+ {draftAllocs.map((a) => ( +
+ { + const next = draftAllocs.map((x) => (x.key === a.key ? { ...x, qty: e.target.value } : x)); + setTransferAlloc(t.id, next); + }} + /> + + {draftAllocs.length > 1 && ( + + )} +
+ ))} + + +
+ +
+
+ {isExactMatch ? ( + + ✓ تخصیص کامل ۱۰۰٪ ({faNum(allocSum)} از {faNum(targetQty)}) آماده تحویل + + ) : ( + + اختلاف تخصیص: مجموع تخصیص ({faNum(allocSum)}) باید دقیقاً با مقدار کل ({faNum(targetQty)}) برابر باشد. + + )} + {hasEmptyWh && ( + + — لطفاً انبار مقصد را انتخاب کنید + + )} +
+ + +
+
+
+
+ ); + })} +
+ )} + + {/* بخش سفارش‌های خرید در راه */} + {orders.map(([quoteId, its]) => { const first = its[0]; const open = expanded.has(quoteId); const hasSubmittable = orderHasSubmittable(quoteId); @@ -401,7 +605,8 @@ export function ArrivalsPage() { )}
); - }) + })} + )} diff --git a/frontend/src/pages/Inventory/ChangeInventoryPage.tsx b/frontend/src/pages/Inventory/ChangeInventoryPage.tsx index 8f40188..7109033 100644 --- a/frontend/src/pages/Inventory/ChangeInventoryPage.tsx +++ b/frontend/src/pages/Inventory/ChangeInventoryPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { Header, PageLayout } from "../../components/layout/Header"; @@ -6,21 +6,22 @@ import { Combobox } from "../../components/Combobox"; import { MultiSelectCombobox } from "../../components/MultiSelectCombobox"; import { useAuthStore } from "../../store/auth"; import { inventorySubheader } from "./panelMenu"; -import { apiV26 } from "../../lib/api"; +import { api, apiV26 } from "../../lib/api"; import { notifyDataChanged } from "../../lib/crossTab"; import { useInventoryData, unitName, whName } from "./useInventoryData"; import { JalaliDatePicker } from "../../components/JalaliDatePicker"; import { uniqueUnits } from "../../lib/units"; -import type { InventoryTransaction, TransactionType } from "../../lib/types"; -import { faNum } from "../../lib/formatters"; +import type { InventoryTransaction, TransactionType, Project, ProjectTransfer } from "../../lib/types"; +import { faNum, fmtDate } from "../../lib/formatters"; import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali"; import { cn } from "../../lib/utils"; -type FormType = TransactionType | "TRANSFER"; +type FormType = TransactionType | "TRANSFER" | "PROJECT_TRANSFER"; const FORM_TYPES: { t: FormType; label: string; dir: string; cls: "in" | "out" | "mv" | "cnt"; color: string; desc: string }[] = [ { t: "PURCHASE", label: "موجودی اولیه", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت موجودی اولیه — متریال/برند/واحد را از فهرست انتخاب کنید؛ اگر نامی در فهرست نبود، همان نام تایپ‌شده هنگام ثبت به‌صورت خودکار در کاتالوگ سراسری ساخته می‌شود. ثبت حداقل یک مشخصه فنی الزامی است." }, { t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدل‌ها (برند و مشخصه متفاوت) نمایش داده می‌شوند. برداشت‌ها در «کنترل روزانه» امروز دیده می‌شوند." }, + { t: "PROJECT_TRANSFER", label: "انتقال بین کارگاهی", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انتقال متریال از انبار این پروژه به یک پروژه دیگر — پس از ثبت، موجودی در انبار مبدأ کسر شده و کالا به عنوان بار در راه برای پروژه مقصد ارسال می‌شود تا مدیر پروژه مقصد آن را تحویل بگیرد." }, { t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#10b981", desc: "برگشت از مصالحی که امروز برداشت شده‌اند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." }, { t: "TRANSFER", label: "انتقال", dir: "جابه‌جایی", cls: "mv", color: "#2563eb", desc: "جابه‌جایی بین انبارهای پروژه — از انبار مبدأ، مقداری از هر مدل را به انبار مقصد منتقل کنید." }, { t: "DAMAGE", label: "ضایعات", dir: "کاهش", cls: "out", color: "#e11d48", desc: "ثبت ضایعات خرید (مثلاً لوله‌های سوراخ) — انبار و متریال را انتخاب و تعداد ضایعات هر مدل را وارد کنید." }, @@ -53,6 +54,22 @@ export function ChangeInventoryPage() { const [type, setType] = useState("PURCHASE"); const [whId, setWhId] = useState(""); + const [toProjectId, setToProjectId] = useState(""); + const [allProjects, setAllProjects] = useState([]); + const [transfers, setTransfers] = useState([]); + const [loadingTransfers, setLoadingTransfers] = useState(false); + + // Edit modal state + const [editingTransfer, setEditingTransfer] = useState(null); + const [editQty, setEditQty] = useState(""); + const [editToProjectId, setEditToProjectId] = useState(""); + const [editNote, setEditNote] = useState(""); + const [editBusy, setEditBusy] = useState(false); + + // Delete modal state + const [deleteConfirmTransfer, setDeleteConfirmTransfer] = useState(null); + const [deleteBusy, setDeleteBusy] = useState(false); + const [fromId, setFromId] = useState(""); const [toId, setToId] = useState(""); const [matTxt, setMatTxt] = useState(""); @@ -194,6 +211,83 @@ export function ChangeInventoryPage() { } }; + const loadTransfers = useCallback(async () => { + if (!user?.project_id) return; + setLoadingTransfers(true); + try { + const list = await apiV26.projectTransfers.list(user.project_id, "outgoing"); + setTransfers(list); + } catch (err) { + console.error("Failed to load project transfers:", err); + } finally { + setLoadingTransfers(false); + } + }, [user?.project_id]); + + useEffect(() => { + api.projects.list().then(setAllProjects).catch(() => {}); + }, []); + + useEffect(() => { + if (type === "PROJECT_TRANSFER") { + void loadTransfers(); + } + }, [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 handleSaveEdit = async () => { + if (!editingTransfer) return; + const n = parseNum(editQty); + if (!(n > 0)) return toast.error("تعداد باید بزرگ‌تر از صفر باشد"); + if (!editToProjectId) return toast.error("پروژه مقصد را انتخاب کنید"); + + setEditBusy(true); + try { + await apiV26.projectTransfers.update(editingTransfer.id, { + quantity: n, + to_project_id: editToProjectId, + note: editNote, + }, user?.project_id); + toast.success("انتقال با موفقیت ویرایش شد ✓"); + notifyDataChanged("inventory"); + setEditingTransfer(null); + await data.refresh(); + await loadTransfers(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "خطا در ویرایش انتقال"); + } finally { + setEditBusy(false); + } + }; + + const handleConfirmDelete = async () => { + if (!deleteConfirmTransfer) return; + setDeleteBusy(true); + try { + await apiV26.projectTransfers.delete(deleteConfirmTransfer.id, user?.project_id); + toast.success("انتقال لغو شد و موجودی به انبار مبدأ بازگردانده شد ✓"); + notifyDataChanged("inventory"); + setDeleteConfirmTransfer(null); + await data.refresh(); + await loadTransfers(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "خطا در لغو انتقال"); + } finally { + setDeleteBusy(false); + } + }; + const submitPurchase = async () => { const matName = matTxt.trim(); const brandName = brandTxt.trim(); @@ -234,6 +328,66 @@ export function ChangeInventoryPage() { } }; + const submitProjectTransfer = async () => { + const matName = matTxt.trim(); + const brandName = brandTxt.trim(); + const unitName = unitTxt.trim(); + const n = parseNum(qty); + 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); + if (!uid) { + const created = await apiV26.catalog.createUnit(unitName); + uid = created.id; + } + let bid = brandIdByName(brandName); + if (!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 }; + } + await apiV26.projectTransfers.create({ + from_warehouse_id: whId, + to_project_id: toProjectId, + material_id: m.id, + brand_id: bid, + unit_id: uid, + quantity: n, + specs: specsFromChips(), + note, + transfer_date: toServerIso(), + }, user?.project_id); + + toast.success("انتقال بین کارگاهی ثبت شد و از انبار مبدأ کسر گردید ✓"); + notifyDataChanged("inventory"); + setQty(""); + setSpecsChips([]); + setNote(""); + setMatTxt(""); + setBrandTxt(""); + setUnitTxt(""); + setToProjectId(""); + await data.refresh(); + await loadTransfers(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "خطا در ثبت انتقال بین کارگاهی"); + } finally { + setBusy(false); + } + }; + const submitList = async (t: TransactionType) => { const ops: Record[] = rows .map((r, i): Record | null => { @@ -356,6 +510,7 @@ export function ChangeInventoryPage() { const doSubmit = () => { try { if (type === "PURCHASE") void submitPurchase(); + else if (type === "PROJECT_TRANSFER") void submitProjectTransfer(); else if (type === "RETURN") void submitReturn(); else if (type === "TRANSFER") void submitTransfer(); else if (type === "FOUND") void submitFound(); @@ -370,6 +525,9 @@ export function ChangeInventoryPage() { if (type === "PURCHASE") { 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); + } 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 === "FOUND") { @@ -463,7 +621,47 @@ export function ChangeInventoryPage() {
{/* انتخاب انبارها (چیپ‌های لمسی به جای سلکت خام) */} - {!isTransfer ? ( + {type === "PROJECT_TRANSFER" ? ( +
+
+ +
+ {whOpts.map((w) => ( + + ))} +
+
+
+ + +
+
+ ) : !isTransfer ? (
@@ -561,8 +759,8 @@ export function ChangeInventoryPage() {
)} - {/* فرم خرید / موجودی اولیه */} - {type === "PURCHASE" && ( + {/* فرم خرید / موجودی اولیه یا انتقال بین کارگاهی */} + {(type === "PURCHASE" || type === "PROJECT_TRANSFER") && (
@@ -637,11 +835,25 @@ export function ChangeInventoryPage() {