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 <cursoragent@cursor.com>
This commit is contained in:
Mortezakoohjani 2026-07-26 20:03:39 +03:30
parent 97a363fc53
commit 9e1fcd9176
8 changed files with 548 additions and 55 deletions

View File

@ -24,9 +24,113 @@ class PurchaseProfileCreate(BaseModel):
inventory_account_id: UUID | None = None inventory_account_id: UUID | None = None
grni_account_id: UUID | None = None grni_account_id: UUID | None = None
liability_account_id: UUID | None = None liability_account_id: UUID | None = None
expense_account_id: UUID | None = None
is_default: bool = False 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): class GoodsReceiptRequest(BaseModel):
source_document_id: str source_document_id: str
amount: Decimal amount: Decimal
@ -46,34 +150,6 @@ class ValuationRequest(BaseModel):
warehouse_id: UUID | None = None 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") @router.post("/preview/goods-receipt")
async def preview_goods_receipt( async def preview_goods_receipt(
body: GoodsReceiptRequest, body: GoodsReceiptRequest,

View File

@ -17,7 +17,14 @@ from app.models.purchase_inventory import (
PurchasePostingProfile, PurchasePostingProfile,
) )
from app.models.sales_accounting import AccountingPreview 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.base import TenantBaseRepository
from app.repositories.foundation import FiscalPeriodRepository from app.repositories.foundation import FiscalPeriodRepository
from app.repositories.posting import VoucherRepository from app.repositories.posting import VoucherRepository
@ -675,9 +682,185 @@ class PurchaseInventoryAccountingService:
stmt = select(PurchasePostingProfile).where( stmt = select(PurchasePostingProfile).where(
PurchasePostingProfile.tenant_id == tenant_id, PurchasePostingProfile.tenant_id == tenant_id,
PurchasePostingProfile.is_default.is_(True), PurchasePostingProfile.is_default.is_(True),
PurchasePostingProfile.is_active.is_(True),
) )
result = await self.session.execute(stmt) result = await self.session.execute(stmt)
profile = result.scalar_one_or_none() profile = result.scalar_one_or_none()
if profile is None: if profile is not None:
raise NotFoundError("پروفایل پیش‌فرض خرید یافت نشد", error_code="profile_not_found") 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 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

View File

@ -4,7 +4,7 @@ export default function Page() {
return ( return (
<SpecializedOpsPage <SpecializedOpsPage
title="پیش‌پرداخت‌ها" title="پیش‌پرداخت‌ها"
description="ثبت پیش‌پرداخت به تأمین‌کنندگان." description="پیش‌پرداخت به تأمین‌کننده — روش پرداخت و صندوق/حساب بانکی را مشخص کنید."
module="purchase" module="purchase"
docType="prepayment" docType="prepayment"
createLabel="پیش‌پرداخت جدید" createLabel="پیش‌پرداخت جدید"
@ -12,11 +12,35 @@ export default function Page() {
{ key: "party", label: "تأمین‌کننده", kind: "party", module: "purchase" }, { key: "party", label: "تأمین‌کننده", kind: "party", module: "purchase" },
{ key: "amount", label: "مبلغ", kind: "money" }, { key: "amount", label: "مبلغ", kind: "money" },
{ key: "due_date", label: "سررسید", kind: "date" }, { key: "due_date", label: "سررسید", kind: "date" },
{ key: "method", label: "روش پرداخت", kind: "select", options: [ {
{ value: "cash", label: "نقد" }, key: "method",
{ value: "bank", label: "بانک" }, label: "روش پرداخت",
{ value: "cheque", label: "چک" }, kind: "select",
]}, options: [
{ value: "cash", label: "نقد / صندوق" },
{ value: "bank", label: "حواله بانکی" },
{ value: "cheque", label: "چک" },
],
},
{
key: "cash_box_id",
label: "صندوق",
kind: "cash_box",
showWhen: { key: "method", value: "cash" },
},
{
key: "bank_account_id",
label: "حساب بانکی",
kind: "bank_account",
showWhen: { key: "method", value: "bank" },
},
{
key: "cheque_number",
label: "شماره چک",
kind: "text",
showWhen: { key: "method", value: "cheque" },
required: false,
},
]} ]}
/> />
); );

View File

@ -85,6 +85,12 @@ export default function PurchaseReturnsPage() {
queryFn: () => accountingApi.setup.getPostingPolicy(tenantId!), queryFn: () => accountingApi.setup.getPostingPolicy(tenantId!),
enabled: !!tenantId, enabled: !!tenantId,
}); });
useQuery({
queryKey: ["accounting", tenantId, "purchase-profiles-ensure"],
queryFn: () => accountingApi.purchaseInventory.ensureDefaultProfile(tenantId!),
enabled: !!tenantId,
staleTime: 60_000,
});
const form = useForm<FormValues>({ const form = useForm<FormValues>({
defaultValues: { defaultValues: {

View File

@ -4,7 +4,7 @@ export default function Page() {
return ( return (
<SpecializedOpsPage <SpecializedOpsPage
title="پیش‌دریافت‌ها" title="پیش‌دریافت‌ها"
description="ثبت پیش‌دریافت از مشتریان با مبلغ و سررسید." description="پیش‌دریافت از مشتری — روش دریافت و صندوق/حساب بانکی را مشخص کنید."
module="sales" module="sales"
docType="pre_receipt" docType="pre_receipt"
createLabel="پیش‌دریافت جدید" createLabel="پیش‌دریافت جدید"
@ -12,11 +12,35 @@ export default function Page() {
{ key: "party", label: "مشتری", kind: "party", module: "sales" }, { key: "party", label: "مشتری", kind: "party", module: "sales" },
{ key: "amount", label: "مبلغ", kind: "money" }, { key: "amount", label: "مبلغ", kind: "money" },
{ key: "due_date", label: "سررسید", kind: "date" }, { key: "due_date", label: "سررسید", kind: "date" },
{ key: "method", label: "روش دریافت", kind: "select", options: [ {
{ value: "cash", label: "نقد" }, key: "method",
{ value: "bank", label: "بانک" }, label: "روش دریافت",
{ value: "cheque", label: "چک" }, kind: "select",
]}, options: [
{ value: "cash", label: "نقد / صندوق" },
{ value: "bank", label: "حواله بانکی" },
{ value: "cheque", label: "چک" },
],
},
{
key: "cash_box_id",
label: "صندوق",
kind: "cash_box",
showWhen: { key: "method", value: "cash" },
},
{
key: "bank_account_id",
label: "حساب بانکی",
kind: "bank_account",
showWhen: { key: "method", value: "bank" },
},
{
key: "cheque_number",
label: "شماره چک",
kind: "text",
showWhen: { key: "method", value: "cheque" },
required: false,
},
]} ]}
/> />
); );

View File

@ -203,7 +203,12 @@ export function OperationalDocumentPage({
}); });
const purchaseProfilesQ = useQuery({ const purchaseProfilesQ = useQuery({
queryKey: ["accounting", tenantId, "purchase-profiles"], 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: enabled:
!!tenantId && !!tenantId &&
(enableAccountingPost === "purchase_gr" || (enableAccountingPost === "purchase_gr" ||

View File

@ -33,8 +33,23 @@ import {
import { PartyCombobox } from "@/components/accounting/EntityCombobox"; import { PartyCombobox } from "@/components/accounting/EntityCombobox";
type FieldDef = 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<string, string>): 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({ export function SpecializedOpsPage({
title, title,
@ -57,6 +72,30 @@ export function SpecializedOpsPage({
const qc = useQueryClient(); const qc = useQueryClient();
const [open, setOpen] = useState(false); 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<string, string>();
for (const b of banksQ.data ?? []) m.set(b.id, b.name);
return m;
}, [banksQ.data]);
const shape: Record<string, z.ZodTypeAny> = { const shape: Record<string, z.ZodTypeAny> = {
number: z.string().optional(), number: z.string().optional(),
doc_date: z.string().min(1, "تاریخ الزامی است"), doc_date: z.string().min(1, "تاریخ الزامی است"),
@ -66,7 +105,12 @@ export function SpecializedOpsPage({
}; };
for (const f of fields) { for (const f of fields) {
if (f.kind === "party") continue; 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); const schema = z.object(shape);
type Values = z.infer<typeof schema>; type Values = z.infer<typeof schema>;
@ -87,6 +131,7 @@ export function SpecializedOpsPage({
}, [fields]); }, [fields]);
const form = useForm<Values>({ resolver: zodResolver(schema), defaultValues: defaults }); const form = useForm<Values>({ resolver: zodResolver(schema), defaultValues: defaults });
const watched = form.watch() as Record<string, string>;
const listQ = useQuery({ const listQ = useQuery({
queryKey: ["accounting", tenantId, "ops-docs", module, docType], queryKey: ["accounting", tenantId, "ops-docs", module, docType],
@ -96,13 +141,41 @@ export function SpecializedOpsPage({
const createM = useMutation({ const createM = useMutation({
mutationFn: (v: Values) => { mutationFn: (v: Values) => {
const values = v as Record<string, string>;
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<string, string> = {}; const meta: Record<string, string> = {};
for (const f of fields) { for (const f of fields) {
if (f.kind === "party") continue; if (f.kind === "party") continue;
const raw = String((v as Record<string, string>)[f.key] ?? ""); if (!isShown(f, values)) continue;
const raw = String(values[f.key] ?? "");
meta[f.key] = f.kind === "money" ? parseMoneyInput(raw) || "0" : raw; 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<string, string>)[amountKey] ?? meta[amountKey] ?? "0")) || "0"; const amount = parseMoneyInput(String(values[amountKey] ?? meta[amountKey] ?? "0")) || "0";
return accountingApi.ops.createDocument(tenantId!, { return accountingApi.ops.createDocument(tenantId!, {
module, module,
doc_type: docType, doc_type: docType,
@ -147,6 +220,21 @@ export function SpecializedOpsPage({
if (!tenantId || listQ.isLoading) return <LoadingState />; if (!tenantId || listQ.isLoading) return <LoadingState />;
if (listQ.error) return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />; if (listQ.error) return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
const methodLabel = (r: Record<string, unknown>) => {
try {
const meta = JSON.parse(String(r.meta_json || "{}")) as Record<string, string>;
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 ( return (
<div> <div>
<PageHeader <PageHeader
@ -165,6 +253,11 @@ export function SpecializedOpsPage({
{ key: "doc_date", header: "تاریخ", render: (r) => <JalaliDateText value={String(r.doc_date ?? "")} /> }, { key: "doc_date", header: "تاریخ", render: (r) => <JalaliDateText value={String(r.doc_date ?? "")} /> },
{ key: "party_name", header: "طرف حساب" }, { key: "party_name", header: "طرف حساب" },
{ key: "amount", header: "مبلغ", render: (r) => formatMoney(String(r.amount ?? "0")) }, { key: "amount", header: "مبلغ", render: (r) => formatMoney(String(r.amount ?? "0")) },
{
key: "method",
header: "روش / حساب",
render: (r) => methodLabel(r),
},
{ {
key: "status", key: "status",
header: "وضعیت", header: "وضعیت",
@ -214,6 +307,7 @@ export function SpecializedOpsPage({
/> />
</FormField> </FormField>
{fields.map((f) => { {fields.map((f) => {
if (!isShown(f, watched)) return null;
if (f.kind === "party") { if (f.kind === "party") {
return ( return (
<FormField key={f.key} label={f.label} className="sm:col-span-2"> <FormField key={f.key} label={f.label} className="sm:col-span-2">
@ -242,16 +336,77 @@ export function SpecializedOpsPage({
control={form.control} control={form.control}
name={f.key as keyof Values} name={f.key as keyof Values}
render={({ field }) => ( render={({ field }) => (
<DatePicker value={String(field.value || "")} onChange={field.onChange} onBlur={field.onBlur} name={field.name} /> <DatePicker
value={String(field.value || "")}
onChange={field.onChange}
onBlur={field.onBlur}
name={field.name}
/>
)} )}
/> />
</FormField> </FormField>
); );
} }
if (f.kind === "cash_box") {
return (
<FormField
key={f.key}
label={f.label}
hint="صندوق محل پرداخت/دریافت نقد"
error={
!(cashBoxesQ.data ?? []).length
? "صندوقی تعریف نشده — از خزانه صندوق بسازید"
: undefined
}
>
<Select {...form.register(f.key as keyof Values)}>
<option value="">انتخاب صندوق</option>
{(cashBoxesQ.data ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.code} {c.name}
</option>
))}
</Select>
</FormField>
);
}
if (f.kind === "bank_account") {
return (
<FormField
key={f.key}
label={f.label}
hint="شماره حساب بانکی مبدأ/مقصد"
error={
!(bankAccountsQ.data ?? []).length
? "حساب بانکی تعریف نشده — از خزانه حساب بانکی بسازید"
: undefined
}
>
<Select {...form.register(f.key as keyof Values)}>
<option value="">انتخاب حساب بانکی</option>
{(bankAccountsQ.data ?? []).map((b) => (
<option key={b.id} value={b.id}>
{(bankNameById.get(b.bank_id) || "بانک") + " — " + b.account_number}
</option>
))}
</Select>
</FormField>
);
}
if (f.kind === "select") { if (f.kind === "select") {
return ( return (
<FormField key={f.key} label={f.label}> <FormField key={f.key} label={f.label}>
<Select {...form.register(f.key as keyof Values)}> <Select
{...form.register(f.key as keyof Values)}
onChange={(e) => {
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);
}
}}
>
<option value="">انتخاب</option> <option value="">انتخاب</option>
{(f.options ?? []).map((o) => ( {(f.options ?? []).map((o) => (
<option key={o.value} value={o.value}> <option key={o.value} value={o.value}>

View File

@ -1707,10 +1707,30 @@ export const accountingApi = {
purchaseInventory: { purchaseInventory: {
listProfiles: (tenantId: string) => listProfiles: (tenantId: string) =>
request<{ id: string; name: string; is_default: boolean; is_active: boolean }[]>( request<
"/api/v1/purchase-inventory/posting-profiles", {
{ tenantId } id: string;
), name: string;
is_default: boolean;
is_active: boolean;
inventory_account_id?: string | null;
grni_account_id?: string | null;
liability_account_id?: string | null;
expense_account_id?: string | null;
}[]
>("/api/v1/purchase-inventory/posting-profiles", { tenantId }),
ensureDefaultProfile: (tenantId: string) =>
request<{
id: string;
name: string;
is_default: boolean;
inventory_account_id?: string | null;
liability_account_id?: string | null;
}>("/api/v1/purchase-inventory/posting-profiles/ensure-default", {
tenantId,
method: "POST",
body: JSON.stringify({}),
}),
createProfile: ( createProfile: (
tenantId: string, tenantId: string,
body: { body: {