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 (
-