From 9e1fcd9176a935c1b75416d057da1e4f28b8c96a Mon Sep 17 00:00:00 2001 From: Mortezakoohjani Date: Sun, 26 Jul 2026 20:03:39 +0330 Subject: [PATCH] Auto-create default purchase posting profile and complete prepayment treasury fields. Ensures GR/returns no longer fail without a profile; cash/bank selection appears by payment method. Co-authored-by: Cursor --- .../app/api/v1/purchase_inventory.py | 132 +++++++++--- .../services/purchase_inventory_service.py | 189 +++++++++++++++++- .../accounting/purchase/prepayments/page.tsx | 36 +++- .../app/accounting/purchase/returns/page.tsx | 6 + .../accounting/sales/pre-receipts/page.tsx | 36 +++- .../accounting/OperationalDocumentPage.tsx | 7 +- .../accounting/SpecializedOpsPage.tsx | 169 +++++++++++++++- frontend/lib/accounting-api.ts | 28 ++- 8 files changed, 548 insertions(+), 55 deletions(-) diff --git a/backend/services/accounting/app/api/v1/purchase_inventory.py b/backend/services/accounting/app/api/v1/purchase_inventory.py index e092a61..2a925a1 100644 --- a/backend/services/accounting/app/api/v1/purchase_inventory.py +++ b/backend/services/accounting/app/api/v1/purchase_inventory.py @@ -24,9 +24,113 @@ class PurchaseProfileCreate(BaseModel): inventory_account_id: UUID | None = None grni_account_id: UUID | None = None liability_account_id: UUID | None = None + expense_account_id: UUID | None = None is_default: bool = False +class PurchaseProfileUpdate(BaseModel): + name: str | None = None + inventory_account_id: UUID | None = None + grni_account_id: UUID | None = None + liability_account_id: UUID | None = None + expense_account_id: UUID | None = None + is_default: bool | None = None + is_active: bool | None = None + + +class PurchaseProfileRepo(TenantBaseRepository[PurchasePostingProfile]): + model = PurchasePostingProfile + + +@router.post("/posting-profiles", status_code=status.HTTP_201_CREATED) +async def create_purchase_profile( + body: PurchaseProfileCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + repo = PurchaseProfileRepo(db) + data = body.model_dump() + if data.get("is_default"): + rows = await repo.list_by_tenant(tenant_id, limit=200) + for r in rows: + r.is_default = False + elif not await repo.list_by_tenant(tenant_id, limit=1): + data["is_default"] = True + entity = PurchasePostingProfile(tenant_id=tenant_id, **data) + await repo.add(entity) + await db.commit() + return {"id": str(entity.id), "name": entity.name} + + +@router.get("/posting-profiles") +async def list_purchase_profiles( + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + rows = await PurchaseProfileRepo(db).list_by_tenant(tenant_id, limit=100) + return [ + { + "id": str(r.id), + "name": r.name, + "is_default": r.is_default, + "is_active": r.is_active, + "inventory_account_id": str(r.inventory_account_id) if r.inventory_account_id else None, + "grni_account_id": str(r.grni_account_id) if r.grni_account_id else None, + "liability_account_id": str(r.liability_account_id) if r.liability_account_id else None, + "expense_account_id": str(r.expense_account_id) if r.expense_account_id else None, + } + for r in rows + ] + + +@router.post("/posting-profiles/ensure-default") +async def ensure_default_purchase_profile( + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + svc = PurchaseInventoryAccountingService(db) + profile = await svc.ensure_default_purchase_profile(tenant_id) + await db.commit() + return { + "id": str(profile.id), + "name": profile.name, + "is_default": profile.is_default, + "inventory_account_id": str(profile.inventory_account_id) if profile.inventory_account_id else None, + "grni_account_id": str(profile.grni_account_id) if profile.grni_account_id else None, + "liability_account_id": str(profile.liability_account_id) if profile.liability_account_id else None, + "expense_account_id": str(profile.expense_account_id) if profile.expense_account_id else None, + } + + +@router.patch("/posting-profiles/{profile_id}") +async def update_purchase_profile( + profile_id: UUID, + body: PurchaseProfileUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + from shared.exceptions import NotFoundError + + repo = PurchaseProfileRepo(db) + entity = await repo.get(tenant_id, profile_id) + if entity is None: + raise NotFoundError("پروفایل خرید یافت نشد", error_code="profile_not_found") + data = body.model_dump(exclude_unset=True) + if data.get("is_default"): + rows = await repo.list_by_tenant(tenant_id, limit=200) + for r in rows: + if r.id != entity.id: + r.is_default = False + for k, v in data.items(): + setattr(entity, k, v) + await db.commit() + return {"id": str(entity.id), "name": entity.name, "is_default": entity.is_default} + + class GoodsReceiptRequest(BaseModel): source_document_id: str amount: Decimal @@ -46,34 +150,6 @@ class ValuationRequest(BaseModel): warehouse_id: UUID | None = None -class PurchaseProfileRepo(TenantBaseRepository[PurchasePostingProfile]): - model = PurchasePostingProfile - - -@router.post("/posting-profiles", status_code=status.HTTP_201_CREATED) -async def create_purchase_profile( - body: PurchaseProfileCreate, - tenant_id: UUID = Depends(require_tenant), - db: AsyncSession = Depends(get_db), - _user: CurrentUser = Depends(get_current_user), -): - repo = PurchaseProfileRepo(db) - entity = PurchasePostingProfile(tenant_id=tenant_id, **body.model_dump()) - await repo.add(entity) - await db.commit() - return {"id": str(entity.id), "name": entity.name} - - -@router.get("/posting-profiles") -async def list_purchase_profiles( - tenant_id: UUID = Depends(require_tenant), - db: AsyncSession = Depends(get_db), - _user: CurrentUser = Depends(get_current_user), -): - rows = await PurchaseProfileRepo(db).list_by_tenant(tenant_id, limit=100) - return [{"id": str(r.id), "name": r.name, "is_default": r.is_default, "is_active": r.is_active} for r in rows] - - @router.post("/preview/goods-receipt") async def preview_goods_receipt( body: GoodsReceiptRequest, diff --git a/backend/services/accounting/app/services/purchase_inventory_service.py b/backend/services/accounting/app/services/purchase_inventory_service.py index fd64c15..2fdf62f 100644 --- a/backend/services/accounting/app/services/purchase_inventory_service.py +++ b/backend/services/accounting/app/services/purchase_inventory_service.py @@ -17,7 +17,14 @@ from app.models.purchase_inventory import ( PurchasePostingProfile, ) from app.models.sales_accounting import AccountingPreview -from app.models.types import InventoryDocumentType, InventoryValuationMethod, PurchaseDocumentType, VoucherStatus +from app.models.types import ( + AccountCategory, + AccountType, + InventoryDocumentType, + InventoryValuationMethod, + PurchaseDocumentType, + VoucherStatus, +) from app.repositories.base import TenantBaseRepository from app.repositories.foundation import FiscalPeriodRepository from app.repositories.posting import VoucherRepository @@ -675,9 +682,185 @@ class PurchaseInventoryAccountingService: stmt = select(PurchasePostingProfile).where( PurchasePostingProfile.tenant_id == tenant_id, PurchasePostingProfile.is_default.is_(True), + PurchasePostingProfile.is_active.is_(True), ) result = await self.session.execute(stmt) profile = result.scalar_one_or_none() - if profile is None: - raise NotFoundError("پروفایل پیش‌فرض خرید یافت نشد", error_code="profile_not_found") + if profile is not None: + return profile + + # Any active profile → promote as default + any_stmt = ( + select(PurchasePostingProfile) + .where( + PurchasePostingProfile.tenant_id == tenant_id, + PurchasePostingProfile.is_active.is_(True), + ) + .order_by(PurchasePostingProfile.created_at.asc()) + .limit(1) + ) + any_result = await self.session.execute(any_stmt) + existing = any_result.scalar_one_or_none() + if existing is not None: + existing.is_default = True + await self.session.flush() + return existing + + return await self.ensure_default_purchase_profile(tenant_id) + + async def ensure_default_purchase_profile(self, tenant_id: UUID) -> PurchasePostingProfile: + """Create (or return) a default purchase posting profile with best-effort GL mapping.""" + stmt = select(PurchasePostingProfile).where( + PurchasePostingProfile.tenant_id == tenant_id, + PurchasePostingProfile.is_default.is_(True), + ) + result = await self.session.execute(stmt) + existing = result.scalar_one_or_none() + if existing is not None: + return existing + + inventory_id = await self._find_account_id( + tenant_id, + name_needles=("موجودی کالا", "موجودی", "انبار", "کالا"), + account_types=(AccountType.ASSET,), + ) + liability_id = await self._find_account_id( + tenant_id, + name_needles=("حساب‌های پرداختنی", "حسابهای پرداختنی", "پرداختنی", "بستانکاران"), + account_types=(AccountType.LIABILITY,), + ) + grni_id = await self._find_account_id( + tenant_id, + name_needles=("GRNI", "کالای در راه", "رسید در راه", "خرید در راه"), + account_types=(AccountType.LIABILITY, AccountType.ASSET), + ) + expense_id = await self._find_account_id( + tenant_id, + name_needles=("بهای تمام‌شده", "بهای تمام شده", "خرید کالا", "هزینه خرید"), + account_types=(AccountType.EXPENSE,), + ) + + # Create missing core accounts on default chart when possible + if inventory_id is None: + inventory_id = await self._create_system_account( + tenant_id, + code="1190", + name="موجودی کالا", + account_type=AccountType.ASSET, + account_category=AccountCategory.CURRENT_ASSET, + ) + if liability_id is None: + liability_id = await self._create_system_account( + tenant_id, + code="2101", + name="حساب‌های پرداختنی", + account_type=AccountType.LIABILITY, + account_category=AccountCategory.CURRENT_LIABILITY, + ) + if grni_id is None: + grni_id = await self._create_system_account( + tenant_id, + code="2105", + name="کالای در راه (GRNI)", + account_type=AccountType.LIABILITY, + account_category=AccountCategory.CURRENT_LIABILITY, + ) + + profile = PurchasePostingProfile( + tenant_id=tenant_id, + name="پروفایل پیش‌فرض خرید", + inventory_account_id=inventory_id, + grni_account_id=grni_id or liability_id, + expense_account_id=expense_id, + liability_account_id=liability_id, + is_default=True, + is_active=True, + ) + await self.purchase_profile_repo.add(profile) + await self.session.flush() return profile + + async def _find_account_id( + self, + tenant_id: UUID, + *, + name_needles: tuple[str, ...], + account_types: tuple[AccountType, ...], + ) -> UUID | None: + from app.models.foundation import Account + from app.models.types import AccountStatus + + stmt = select(Account).where( + Account.tenant_id == tenant_id, + Account.is_postable.is_(True), + Account.is_group.is_(False), + Account.status == AccountStatus.ACTIVE, + Account.account_type.in_(account_types), + ) + result = await self.session.execute(stmt) + rows = list(result.scalars().all()) + lowered = [(a, (a.name or "").lower()) for a in rows] + for needle in name_needles: + n = needle.lower() + for acc, name in lowered: + if n in name: + return acc.id + return rows[0].id if rows else None + + async def _create_system_account( + self, + tenant_id: UUID, + *, + code: str, + name: str, + account_type: AccountType, + account_category: AccountCategory, + ) -> UUID | None: + from app.models.foundation import Account, ChartOfAccounts + from app.models.types import AccountStatus + + chart_stmt = select(ChartOfAccounts).where( + ChartOfAccounts.tenant_id == tenant_id, + ChartOfAccounts.is_active.is_(True), + ).order_by(ChartOfAccounts.is_default.desc()).limit(1) + chart = (await self.session.execute(chart_stmt)).scalar_one_or_none() + if chart is None: + return None + + existing = ( + await self.session.execute( + select(Account).where(Account.tenant_id == tenant_id, Account.code == code) + ) + ).scalar_one_or_none() + if existing is not None: + return existing.id + + # Avoid unique name collisions by suffixing if needed + final_code = code + for i in range(0, 20): + candidate = code if i == 0 else f"{code}-{i}" + clash = ( + await self.session.execute( + select(Account.id).where(Account.tenant_id == tenant_id, Account.code == candidate) + ) + ).scalar_one_or_none() + if clash is None: + final_code = candidate + break + + acc = Account( + tenant_id=tenant_id, + chart_id=chart.id, + code=final_code, + name=name, + account_type=account_type, + account_category=account_category, + level=3, + is_group=False, + is_postable=True, + status=AccountStatus.ACTIVE, + description="ایجاد خودکار برای پروفایل خرید", + ) + self.session.add(acc) + await self.session.flush() + return acc.id diff --git a/frontend/app/accounting/purchase/prepayments/page.tsx b/frontend/app/accounting/purchase/prepayments/page.tsx index 9dedfec..47da0f3 100644 --- a/frontend/app/accounting/purchase/prepayments/page.tsx +++ b/frontend/app/accounting/purchase/prepayments/page.tsx @@ -4,7 +4,7 @@ export default function Page() { return ( ); diff --git a/frontend/app/accounting/purchase/returns/page.tsx b/frontend/app/accounting/purchase/returns/page.tsx index ea82681..9cc8134 100644 --- a/frontend/app/accounting/purchase/returns/page.tsx +++ b/frontend/app/accounting/purchase/returns/page.tsx @@ -85,6 +85,12 @@ export default function PurchaseReturnsPage() { queryFn: () => accountingApi.setup.getPostingPolicy(tenantId!), enabled: !!tenantId, }); + useQuery({ + queryKey: ["accounting", tenantId, "purchase-profiles-ensure"], + queryFn: () => accountingApi.purchaseInventory.ensureDefaultProfile(tenantId!), + enabled: !!tenantId, + staleTime: 60_000, + }); const form = useForm({ defaultValues: { diff --git a/frontend/app/accounting/sales/pre-receipts/page.tsx b/frontend/app/accounting/sales/pre-receipts/page.tsx index 411ebdf..c926f6d 100644 --- a/frontend/app/accounting/sales/pre-receipts/page.tsx +++ b/frontend/app/accounting/sales/pre-receipts/page.tsx @@ -4,7 +4,7 @@ export default function Page() { return ( ); diff --git a/frontend/components/accounting/OperationalDocumentPage.tsx b/frontend/components/accounting/OperationalDocumentPage.tsx index 86176d7..7771aa1 100644 --- a/frontend/components/accounting/OperationalDocumentPage.tsx +++ b/frontend/components/accounting/OperationalDocumentPage.tsx @@ -203,7 +203,12 @@ export function OperationalDocumentPage({ }); const purchaseProfilesQ = useQuery({ queryKey: ["accounting", tenantId, "purchase-profiles"], - queryFn: () => accountingApi.purchaseInventory.listProfiles(tenantId!), + queryFn: async () => { + const rows = await accountingApi.purchaseInventory.listProfiles(tenantId!); + if (rows.some((p) => p.is_default && p.is_active)) return rows; + await accountingApi.purchaseInventory.ensureDefaultProfile(tenantId!).catch(() => null); + return accountingApi.purchaseInventory.listProfiles(tenantId!); + }, enabled: !!tenantId && (enableAccountingPost === "purchase_gr" || diff --git a/frontend/components/accounting/SpecializedOpsPage.tsx b/frontend/components/accounting/SpecializedOpsPage.tsx index e945f9f..b7e6597 100644 --- a/frontend/components/accounting/SpecializedOpsPage.tsx +++ b/frontend/components/accounting/SpecializedOpsPage.tsx @@ -33,8 +33,23 @@ import { import { PartyCombobox } from "@/components/accounting/EntityCombobox"; type FieldDef = - | { key: string; label: string; kind: "text" | "money" | "date" | "select"; options?: { value: string; label: string }[]; required?: boolean } - | { key: string; label: string; kind: "party"; module?: string; required?: boolean }; + | { + key: string; + label: string; + kind: "text" | "money" | "date" | "select" | "cash_box" | "bank_account"; + options?: { value: string; label: string }[]; + required?: boolean; + /** Show field only when another field equals this value */ + showWhen?: { key: string; value: string | string[] }; + } + | { key: string; label: string; kind: "party"; module?: string; required?: boolean; showWhen?: { key: string; value: string | string[] } }; + +function isShown(f: FieldDef, values: Record): boolean { + if (!f.showWhen) return true; + const current = values[f.showWhen.key] ?? ""; + const expected = f.showWhen.value; + return Array.isArray(expected) ? expected.includes(current) : current === expected; +} export function SpecializedOpsPage({ title, @@ -57,6 +72,30 @@ export function SpecializedOpsPage({ const qc = useQueryClient(); const [open, setOpen] = useState(false); + const needsCash = fields.some((f) => f.kind === "cash_box"); + const needsBank = fields.some((f) => f.kind === "bank_account"); + + const cashBoxesQ = useQuery({ + queryKey: ["accounting", tenantId, "cash-boxes"], + queryFn: () => accountingApi.treasury.listCashBoxes(tenantId!), + enabled: !!tenantId && needsCash, + }); + const bankAccountsQ = useQuery({ + queryKey: ["accounting", tenantId, "bank-accounts"], + queryFn: () => accountingApi.treasury.listBankAccounts(tenantId!), + enabled: !!tenantId && needsBank, + }); + const banksQ = useQuery({ + queryKey: ["accounting", tenantId, "banks"], + queryFn: () => accountingApi.treasury.listBanks(tenantId!), + enabled: !!tenantId && needsBank, + }); + const bankNameById = useMemo(() => { + const m = new Map(); + for (const b of banksQ.data ?? []) m.set(b.id, b.name); + return m; + }, [banksQ.data]); + const shape: Record = { number: z.string().optional(), doc_date: z.string().min(1, "تاریخ الزامی است"), @@ -66,7 +105,12 @@ export function SpecializedOpsPage({ }; for (const f of fields) { if (f.kind === "party") continue; - shape[f.key] = f.required === false ? z.string().optional() : z.string().min(1, `${f.label} الزامی است`); + // Conditional fields validated in submit — keep optional in zod + if (f.showWhen) { + shape[f.key] = z.string().optional(); + } else { + shape[f.key] = f.required === false ? z.string().optional() : z.string().min(1, `${f.label} الزامی است`); + } } const schema = z.object(shape); type Values = z.infer; @@ -87,6 +131,7 @@ export function SpecializedOpsPage({ }, [fields]); const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults }); + const watched = form.watch() as Record; const listQ = useQuery({ queryKey: ["accounting", tenantId, "ops-docs", module, docType], @@ -96,13 +141,41 @@ export function SpecializedOpsPage({ const createM = useMutation({ mutationFn: (v: Values) => { + const values = v as Record; + for (const f of fields) { + if (f.kind === "party") continue; + if (!isShown(f, values)) continue; + if (f.required === false) continue; + const raw = String(values[f.key] ?? "").trim(); + if (!raw || (f.kind === "money" && Number(parseMoneyInput(raw) || "0") <= 0 && f.key === amountKey)) { + if (f.kind === "money" && f.key === amountKey && Number(parseMoneyInput(raw) || "0") <= 0) { + throw new Error(`${f.label} باید بزرگ‌تر از صفر باشد`); + } + if (f.kind !== "money" && !raw) { + throw new Error(`${f.label} الزامی است`); + } + } + } + const meta: Record = {}; for (const f of fields) { if (f.kind === "party") continue; - const raw = String((v as Record)[f.key] ?? ""); + if (!isShown(f, values)) continue; + const raw = String(values[f.key] ?? ""); meta[f.key] = f.kind === "money" ? parseMoneyInput(raw) || "0" : raw; + if (f.kind === "cash_box") { + const box = (cashBoxesQ.data ?? []).find((c) => c.id === raw); + if (box) meta.cash_box_name = box.name; + } + if (f.kind === "bank_account") { + const bank = (bankAccountsQ.data ?? []).find((b) => b.id === raw); + if (bank) { + meta.bank_account_number = bank.account_number; + meta.bank_name = bankNameById.get(bank.bank_id) || ""; + } + } } - const amount = parseMoneyInput(String((v as Record)[amountKey] ?? meta[amountKey] ?? "0")) || "0"; + const amount = parseMoneyInput(String(values[amountKey] ?? meta[amountKey] ?? "0")) || "0"; return accountingApi.ops.createDocument(tenantId!, { module, doc_type: docType, @@ -147,6 +220,21 @@ export function SpecializedOpsPage({ if (!tenantId || listQ.isLoading) return ; if (listQ.error) return listQ.refetch()} />; + const methodLabel = (r: Record) => { + try { + const meta = JSON.parse(String(r.meta_json || "{}")) as Record; + if (meta.method === "cash") return meta.cash_box_name ? `نقد — ${meta.cash_box_name}` : "نقد"; + if (meta.method === "bank") + return meta.bank_account_number + ? `بانک — ${meta.bank_name ? meta.bank_name + " " : ""}${meta.bank_account_number}` + : "بانک"; + if (meta.method === "cheque") return "چک"; + return meta.method || "—"; + } catch { + return "—"; + } + }; + return (
}, { key: "party_name", header: "طرف حساب" }, { key: "amount", header: "مبلغ", render: (r) => formatMoney(String(r.amount ?? "0")) }, + { + key: "method", + header: "روش / حساب", + render: (r) => methodLabel(r), + }, { key: "status", header: "وضعیت", @@ -214,6 +307,7 @@ export function SpecializedOpsPage({ /> {fields.map((f) => { + if (!isShown(f, watched)) return null; if (f.kind === "party") { return ( @@ -242,16 +336,77 @@ export function SpecializedOpsPage({ control={form.control} name={f.key as keyof Values} render={({ field }) => ( - + )} /> ); } + if (f.kind === "cash_box") { + return ( + + + + ); + } + if (f.kind === "bank_account") { + return ( + + + + ); + } if (f.kind === "select") { return ( - { + form.setValue(f.key as keyof Values, e.target.value as never); + // Clear dependent treasury fields when method changes + if (f.key === "method") { + form.setValue("cash_box_id" as never, "" as never); + form.setValue("bank_account_id" as never, "" as never); + } + }} + > {(f.options ?? []).map((o) => (