From 42fb31483426bdae1a6a7e4e0cde2149c3f303b5 Mon Sep 17 00:00:00 2001 From: "reza.g" Date: Mon, 19 Oct 2026 15:07:11 +0330 Subject: [PATCH] feat(inventory): add mandatory per-section date pickers, prevent future dates, sync transfer dates, and sort by transaction_date --- backend/app/routers/inventory.py | 6 +- backend/app/services/inventory_service.py | 12 +- frontend/src/components/JalaliDatePicker.tsx | 67 +++- frontend/src/lib/types.ts | 1 + .../pages/Inventory/ChangeInventoryPage.tsx | 310 +++++++++++++----- .../src/pages/Inventory/DailyControlPage.tsx | 2 +- .../src/pages/Inventory/InventoryPage.tsx | 165 +++++++++- sql/migration.sql | 2 +- 8 files changed, 457 insertions(+), 108 deletions(-) diff --git a/backend/app/routers/inventory.py b/backend/app/routers/inventory.py index 95cd4bf..760a817 100644 --- a/backend/app/routers/inventory.py +++ b/backend/app/routers/inventory.py @@ -217,6 +217,7 @@ def transaction_to_dict(tx: InventoryTransaction, wh: Warehouse | None = None) - "performed_by": tx.performed_by, "delivered_to": tx.delivered_to, "transaction_date": to_iso(tx.transaction_date), + "created_at": to_iso(tx.created_at) if hasattr(tx, "created_at") and tx.created_at else None, "note": tx.note, } @@ -461,7 +462,10 @@ async def list_transactions( InventoryTransaction.transaction_date < datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc) + timedelta(days=1) ) - stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date") + if not sort or sort == "-transaction_date": + stmt = stmt.order_by(InventoryTransaction.transaction_date.desc(), InventoryTransaction.created_at.desc()) + else: + stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date") items, meta = await paginate(stmt, page, page_size, db) # Enrich: warehouse name + material/brand/specs/unit for the UI rows. diff --git a/backend/app/services/inventory_service.py b/backend/app/services/inventory_service.py index f335d62..e723fbd 100644 --- a/backend/app/services/inventory_service.py +++ b/backend/app/services/inventory_service.py @@ -22,6 +22,7 @@ SHA256 of the sorted canonical specs JSON (or 'default' when empty). import hashlib import json import uuid +from datetime import datetime, timezone from decimal import Decimal from sqlalchemy import select @@ -175,6 +176,7 @@ async def apply_transaction( t = payload.transaction_type specs = payload.specs or {} specs_hash = canonical_specs_hash(specs) + tx_date = payload.transaction_date or datetime.now(timezone.utc) # STOCK_COUNT: apply the difference between counted and recorded quantity. if t == "STOCK_COUNT": @@ -197,7 +199,7 @@ async def apply_transaction( recorded_qty=recorded, physical_qty=payload.physical_qty, performed_by=performed_by, - transaction_date=payload.transaction_date, + transaction_date=tx_date, note=payload.note, ) db.add(tx) @@ -238,7 +240,7 @@ async def apply_transaction( quantity=-quantity, transfer_pair_id=pair_id, performed_by=performed_by, - transaction_date=payload.transaction_date, + transaction_date=tx_date, note=payload.note, ) db.add(out_tx) @@ -256,7 +258,7 @@ async def apply_transaction( quantity=quantity, transfer_pair_id=pair_id, performed_by=performed_by, - transaction_date=payload.transaction_date, + transaction_date=tx_date, note=payload.note, ) db.add(in_tx) @@ -291,7 +293,7 @@ async def apply_transaction( quantity=quantity, performed_by=performed_by, delivered_to=payload.delivered_to, - transaction_date=payload.transaction_date, + transaction_date=tx_date, note=payload.note, ) db.add(tx) @@ -321,7 +323,7 @@ async def apply_transaction( quantity=-quantity, performed_by=performed_by, delivered_to=payload.delivered_to, - transaction_date=payload.transaction_date, + transaction_date=tx_date, note=payload.note, ) db.add(tx) diff --git a/frontend/src/components/JalaliDatePicker.tsx b/frontend/src/components/JalaliDatePicker.tsx index 17b3f1d..e6fc778 100644 --- a/frontend/src/components/JalaliDatePicker.tsx +++ b/frontend/src/components/JalaliDatePicker.tsx @@ -21,6 +21,7 @@ export interface JalaliDatePickerProps { className?: string; withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید) disabled?: boolean; + maxDate?: Date | string; // حداکثر تاریخ مجاز (جلوگیری از انتخاب تاریخ/زمان آینده) } function todayJalali(): [number, number, number] { @@ -76,6 +77,7 @@ export function JalaliDatePicker({ className, withTime = false, disabled = false, + maxDate, }: JalaliDatePickerProps) { const [open, setOpen] = useState(false); const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali()); @@ -83,6 +85,17 @@ export function JalaliDatePicker({ const [hourText, setHourText] = useState(() => faNum(String(initialTime.h).padStart(2, "0"))); const [minuteText, setMinuteText] = useState(() => faNum(String(initialTime.m).padStart(2, "0"))); + const maxDateObj = useMemo(() => { + if (!maxDate) return null; + const d = typeof maxDate === "string" ? new Date(maxDate) : maxDate; + return Number.isNaN(d.getTime()) ? null : d; + }, [maxDate]); + + const maxDateIso = useMemo(() => { + if (!maxDateObj) return null; + return `${maxDateObj.getFullYear()}-${String(maxDateObj.getMonth() + 1).padStart(2, "0")}-${String(maxDateObj.getDate()).padStart(2, "0")}`; + }, [maxDateObj]); + const [text, setText] = useState(() => { if (!value) return ""; const j = isoToJalaliStr(value); @@ -145,15 +158,16 @@ export function JalaliDatePicker({ const dim = daysInMonth(jy, jm); const today = new Date(); const tIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; - const cells: { d: number; iso: string; isToday: boolean }[] = []; - for (let i = 0; i < firstWd; i++) cells.push({ d: 0, iso: "", isToday: false }); + const cells: { d: number; iso: string; isToday: boolean; disabled: boolean }[] = []; + for (let i = 0; i < firstWd; i++) cells.push({ d: 0, iso: "", isToday: false, disabled: false }); for (let d = 1; d <= dim; d++) { const [gy, gm, gd] = jalaliToGregorian(jy, jm, d); const iso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`; - cells.push({ d, iso, isToday: iso === tIso }); + const isDisabled = !!(maxDateIso && iso > maxDateIso); + cells.push({ d, iso, isToday: iso === tIso, disabled: isDisabled }); } return cells; - }, [curJ]); + }, [curJ, maxDateIso]); const nav = (dir: string) => { setCurJ(([jy, jm]) => { @@ -176,10 +190,22 @@ export function JalaliDatePicker({ }; const selectDay = (jy: number, jm: number, jd: number) => { - const currentH = parseInt(toAsciiDigits(hourText) || "0", 10); - const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10); - const validH = Math.min(23, Math.max(0, currentH)); - const validM = Math.min(59, Math.max(0, currentM)); + const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd); + const dayIso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`; + if (maxDateIso && dayIso > maxDateIso) return; + + let currentH = parseInt(toAsciiDigits(hourText) || "0", 10); + let currentM = parseInt(toAsciiDigits(minuteText) || "0", 10); + let validH = Math.min(23, Math.max(0, currentH)); + let validM = Math.min(59, Math.max(0, currentM)); + if (maxDateObj && dayIso === maxDateIso) { + if (validH > maxDateObj.getHours() || (validH === maxDateObj.getHours() && validM > maxDateObj.getMinutes())) { + validH = maxDateObj.getHours(); + validM = maxDateObj.getMinutes(); + setHourText(faNum(String(validH).padStart(2, "0"))); + setMinuteText(faNum(String(validM).padStart(2, "0"))); + } + } const val = withTime ? buildIso(jy, jm, jd, validH, validM) : buildDateOnly(jy, jm, jd); onChange(val); setText( @@ -267,8 +293,19 @@ export function JalaliDatePicker({ const pickToday = () => { const now = new Date(); const [jy, jm, jd] = todayJalali(); - const nh = now.getHours(); - const nm = now.getMinutes(); + let nh = now.getHours(); + let nm = now.getMinutes(); + if (maxDateObj) { + const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd); + const todayIso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`; + if (maxDateIso && todayIso > maxDateIso) return; + if (todayIso === maxDateIso) { + if (nh > maxDateObj.getHours() || (nh === maxDateObj.getHours() && nm > maxDateObj.getMinutes())) { + nh = maxDateObj.getHours(); + nm = maxDateObj.getMinutes(); + } + } + } setHourText(faNum(String(nh).padStart(2, "0"))); setMinuteText(faNum(String(nm).padStart(2, "0"))); const val = withTime @@ -415,6 +452,16 @@ export function JalaliDatePicker({ {grid.map((cell, i) => cell.d === 0 ? (
+ ) : cell.disabled ? ( + ) : (
{type === "WITHDRAWAL" && ( -
+
+
+ + setDeliveredTo(e.target.value)} + /> +
+
+ + setSectionDate("WITHDRAWAL", iso)} + placeholder="۱۴۰۳/۰۵/۱۲ --:--" + withTime + maxDate={new Date()} + /> +
+
+ )} + + {(type === "DAMAGE" || type === "WASTE" || type === "LOSS") && ( +
+ setSectionDate(type, iso)} + placeholder="۱۴۰۳/۰۵/۱۲ --:--" + withTime + maxDate={new Date()} + /> +
+
+ setDeliveredTo(e.target.value)} + placeholder="فیلتر بر اساس نام متریال، برند یا مشخصه (خالی = نمایش همه)…" + value={matTxt} + onChange={(e) => { setMatTxt(e.target.value); setRows([]); }} /> + + {data.lookup.materials.map((m) =>
)} - {(type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT") && ( -
- - { setMatTxt(e.target.value); setRows([]); }} - /> - - {data.lookup.materials.map((m) => + {type === "STOCK_COUNT" && ( +
+
+ + setSectionDate("STOCK_COUNT", iso)} + placeholder="۱۴۰۳/۰۵/۱۲ --:--" + withTime + maxDate={new Date()} + /> +
+
+ + { setMatTxt(e.target.value); setRows([]); }} + /> + + {data.lookup.materials.map((m) => +
)}
)} - {/* فیلتر نام متریال برای انتقال داخلی و برگشت و پیدا شده */} + {/* فیلتر نام متریال و تاریخ برای انتقال داخلی */} {isTransfer && ( -
- - { setMatTxt(e.target.value); setRows([]); }} - /> - - {data.lookup.materials.map((m) => +
+
+ + setSectionDate("TRANSFER", iso)} + placeholder="۱۴۰۳/۰۵/۱۲ --:--" + withTime + maxDate={new Date()} + /> +
+
+ + { setMatTxt(e.target.value); setRows([]); }} + /> + + {data.lookup.materials.map((m) => +
)} @@ -1073,12 +1178,15 @@ export function ChangeInventoryPage() { />
- + setDateInput(iso ?? new Date().toISOString())} - placeholder="۱۴۰۳/۰۵/۱۲" + value={sectionDates.PURCHASE} + onChange={(iso) => setSectionDate("PURCHASE", iso)} + placeholder="۱۴۰۳/۰۵/۱۲ --:--" withTime + maxDate={new Date()} />
@@ -1097,18 +1205,32 @@ export function ChangeInventoryPage() {
)} - {/* ---------- انتقال بین کارگاهی: یادداشت بار + لیست اقلام ---------- */} + {/* ---------- انتقال بین کارگاهی: یادداشت بار + تاریخ + لیست اقلام ---------- */} {type === "PROJECT_TRANSFER" && (
-
- - setNote(e.target.value)} - /> +
+
+ + setSectionDate("PROJECT_TRANSFER", iso)} + placeholder="۱۴۰۳/۰۵/۱۲ --:--" + withTime + maxDate={new Date()} + /> +
+
+ + setNote(e.target.value)} + /> +
@@ -1135,19 +1257,33 @@ export function ChangeInventoryPage() {
)} - {/* ---------- برگشت: فیلتر بازه + لیست برداشت‌ها ---------- */} + {/* ---------- برگشت: فیلتر بازه + تاریخ ثبت برگشت + لیست برداشت‌ها ---------- */} {type === "RETURN" && (
-
- - setRetPreset(p)} - customFrom={retCustomFrom} - customTo={retCustomTo} - onCustomFrom={(iso) => setRetCustomFrom(iso)} - onCustomTo={(iso) => setRetCustomTo(iso)} - /> +
+
+ + setSectionDate("RETURN", iso)} + placeholder="۱۴۰۳/۰۵/۱۲ --:--" + withTime + maxDate={new Date()} + /> +
+
+ + setRetPreset(p)} + customFrom={retCustomFrom} + customTo={retCustomTo} + onCustomFrom={(iso) => setRetCustomFrom(iso)} + onCustomTo={(iso) => setRetCustomTo(iso)} + /> +
@@ -1208,14 +1344,28 @@ export function ChangeInventoryPage() { )} {type === "FOUND" && ( - unitName(data.lookup.units, id)} - emptyMsg={foundEmptyMsg} - loading={foundLoading && !pendingLosses.length} - /> +
+
+ + setSectionDate("FOUND", iso)} + placeholder="۱۴۰۳/۰۵/۱۲ --:--" + withTime + maxDate={new Date()} + /> +
+ unitName(data.lookup.units, id)} + emptyMsg={foundEmptyMsg} + loading={foundLoading && !pendingLosses.length} + /> +
)} {/* دکمه ثبت — دسکتاپ */} @@ -1533,7 +1683,7 @@ export function ChangeInventoryPage() { ? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد هم‌زمان ثبت می‌شود." : type === "RETURN" ? "برگشت، دفتر را تغییر نمی‌دهد؛ تراکنش جدیدی ثبت می‌شود و مانده قابل برگشت هر برداشت به‌روز می‌ماند." - : `تاریخ انتخابی: ${jalDateLabel(new Date(dateInput))} — با «${jalDateLabel(new Date())}» برابر است.`} + : `تاریخ انتخابی: ${jalDateLabel(new Date(sectionDates[type] || new Date()))}`}
diff --git a/frontend/src/pages/Inventory/DailyControlPage.tsx b/frontend/src/pages/Inventory/DailyControlPage.tsx index 11ccbae..6b2bb47 100644 --- a/frontend/src/pages/Inventory/DailyControlPage.tsx +++ b/frontend/src/pages/Inventory/DailyControlPage.tsx @@ -140,7 +140,7 @@ export function DailyControlPage() { if (t.transaction_date > g.last) g.last = t.transaction_date; } }); - return [...gmap.values()]; + return [...gmap.values()].sort((a, b) => new Date(b.last).getTime() - new Date(a.last).getTime()); }, [withdrawals]); const isToday = sel === todayKey(); diff --git a/frontend/src/pages/Inventory/InventoryPage.tsx b/frontend/src/pages/Inventory/InventoryPage.tsx index cacfe6c..e6869a8 100644 --- a/frontend/src/pages/Inventory/InventoryPage.tsx +++ b/frontend/src/pages/Inventory/InventoryPage.tsx @@ -12,6 +12,22 @@ import type { InventoryRow, InventoryTransaction, TransactionType } from "../../ import { faNum } from "../../lib/formatters"; import { isoToJalaliStr } from "../../lib/jalali"; import { cn } from "../../lib/utils"; +import { JalaliDatePicker } from "../../components/JalaliDatePicker"; + +const TX_TYPE_OPTIONS: { type: string; label: string }[] = [ + { type: "", label: "همه عملیات‌ها" }, + { type: "PURCHASE", label: "موجودی اولیه / خرید" }, + { type: "WITHDRAWAL", label: "خروج / مصرف" }, + { type: "PROJECT_TRANSFER", label: "انتقال بین کارگاهی" }, + { type: "RETURN", label: "برگشت به انبار" }, + { type: "TRANSFER_OUT", label: "انتقال بین انبارها (خروجی)" }, + { type: "TRANSFER_IN", label: "انتقال بین انبارها (ورودی)" }, + { type: "DAMAGE", label: "خرابی" }, + { type: "WASTE", label: "ضایعات" }, + { type: "LOSS", label: "مفقودی" }, + { type: "FOUND", label: "پیدا شده" }, + { type: "STOCK_COUNT", label: "انبارگردانی / شمارش" }, +]; // رنگ‌بندی معنایی تراکنش‌ها const TX_META: Record = { @@ -58,10 +74,20 @@ export function InventoryPage() { const canModify = isSupervisor || isAdmin || user?.panels?.inventory === "limited"; const [params] = useSearchParams(); - const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({ + const [filters, setFilters] = useState<{ + warehouse: string; + material: string; + project: string; + transactionType: string; + dateFrom: string | null; + dateTo: string | null; + }>({ warehouse: params.get("warehouse") || "", material: "", project: "", + transactionType: "", + dateFrom: null, + dateTo: null, }); const [sort, setSort] = useState<"asc" | "desc" | "">(""); const [showMobileFilters, setShowMobileFilters] = useState(false); @@ -77,14 +103,40 @@ export function InventoryPage() { const visibleIds = useMemo(() => new Set(visibleWarehouses.map((w) => w.id)), [visibleWarehouses]); + // فیلتر تراکنش‌ها بر اساس نوع و بازه زمانی روزانه (۰۰:۰۰:۰۰ تا ۲۳:۵۹:۵۹) + const filteredTransactions = useMemo(() => { + let list = transactions; + if (filters.transactionType) { + list = list.filter((t) => t.transaction_type === filters.transactionType); + } + if (filters.dateFrom) { + const dFrom = new Date(filters.dateFrom); + const startOfDay = new Date(dFrom.getFullYear(), dFrom.getMonth(), dFrom.getDate(), 0, 0, 0, 0); + list = list.filter((t) => new Date(t.transaction_date).getTime() >= startOfDay.getTime()); + } + if (filters.dateTo) { + const dTo = new Date(filters.dateTo); + const endOfDay = new Date(dTo.getFullYear(), dTo.getMonth(), dTo.getDate(), 23, 59, 59, 999); + list = list.filter((t) => new Date(t.transaction_date).getTime() <= endOfDay.getTime()); + } + return list; + }, [transactions, filters.transactionType, filters.dateFrom, filters.dateTo]); + + const hasTxFilter = !!(filters.transactionType || filters.dateFrom || filters.dateTo); + const groups = useMemo(() => { const gmap = new Map(); const order: string[] = []; + + // اگر فیلتر تراکنش فعال است، فقط اقلام دارای تراکنش منطبق در خروجی ظاهر می‌شوند + const activeInvIds = hasTxFilter ? new Set(filteredTransactions.map((t) => t.inventory_id)) : null; + inventory.forEach((r) => { const whId = r.warehouse?.id; if (!whId || !visibleIds.has(whId)) return; if (filters.warehouse && whId !== filters.warehouse) return; if (filters.material && !r.material.name.includes(filters.material.trim())) return; + if (activeInvIds && !activeInvIds.has(r.id)) return; const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`; let g = gmap.get(gk); if (!g) { @@ -104,7 +156,6 @@ export function InventoryPage() { order.push(gk); } g.qty += r.quantity_number || 0; - if (r.updated_at > g.updatedAt) g.updatedAt = r.updated_at; g.recs.push(r); if (r.brand) { if (!(r.brand.id in g.brands)) { @@ -114,26 +165,61 @@ export function InventoryPage() { g.brands[r.brand.id] += r.quantity_number || 0; } }); + + // استخراج آخرین تاریخ تراکنش واقعی کاربر برای هر گروه و انتساب به updatedAt + order.forEach((k) => { + const g = gmap.get(k); + if (!g) return; + const groupAllTxs = transactions.filter((t) => g.recs.some((r) => r.id === t.inventory_id)); + if (groupAllTxs.length > 0) { + const latestTx = groupAllTxs.reduce((latest, t) => { + return new Date(t.transaction_date).getTime() > new Date(latest.transaction_date).getTime() ? t : latest; + }, groupAllTxs[0]); + g.updatedAt = latestTx.transaction_date; + } + }); + const out = order.map((k) => gmap.get(k)!); if (sort === "asc") out.sort((a, b) => a.qty - b.qty); else if (sort === "desc") out.sort((a, b) => b.qty - a.qty); else out.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); return out; - }, [inventory, visibleIds, filters, sort]); + }, [inventory, visibleIds, filters, sort, hasTxFilter, filteredTransactions, transactions]); const groupTxs = (g: Group) => - transactions + filteredTransactions .filter((t) => g.recs.some((r) => r.id === t.inventory_id)) .slice() - .sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()); + .sort((a, b) => { + const da = new Date(a.transaction_date).getTime(); + const db = new Date(b.transaction_date).getTime(); + if (da !== db) return da - db; + const ca = a.created_at ? new Date(a.created_at).getTime() : 0; + const cb = b.created_at ? new Date(b.created_at).getTime() : 0; + return ca - cb; + }); const brandTxs = (g: Group, bid: string) => - transactions + filteredTransactions .filter((t) => t.brand?.id === bid && g.recs.some((r) => r.id === t.inventory_id)) .slice() - .sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()); + .sort((a, b) => { + const da = new Date(a.transaction_date).getTime(); + const db = new Date(b.transaction_date).getTime(); + if (da !== db) return da - db; + const ca = a.created_at ? new Date(a.created_at).getTime() : 0; + const cb = b.created_at ? new Date(b.created_at).getTime() : 0; + return ca - cb; + }); - const hasFilter = !!(filters.warehouse || (isAdmin && filters.project)); + const hasFilter = !!( + filters.warehouse || + (isAdmin && filters.project) || + filters.material || + filters.transactionType || + filters.dateFrom || + filters.dateTo + ); if (loading) return
; if (!user) { @@ -285,13 +371,72 @@ export function InventoryPage() {
)} + {/* نوع عملیات / بخش */} +
+
نوع عملیات / بخش:
+
+ {TX_TYPE_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* فیلتر بازه تاریخ یا روز مشخص */} +
+
+ فیلتر بازه روزانه (بازه کامل ۰۰:۰۰ تا ۲۳:۵۹): + +
+
+
+ + setFilters((f) => ({ ...f, dateFrom: val, dateTo: f.dateTo || val }))} + placeholder="از تاریخ…" + maxDate={new Date()} + /> +
+
+ + setFilters((f) => ({ ...f, dateTo: val }))} + placeholder="تا تاریخ…" + maxDate={new Date()} + /> +
+
+
+ {/* دکمه پاک کردن فیلترها */} {hasFilter && (