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:
parent
97a363fc53
commit
9e1fcd9176
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -4,7 +4,7 @@ export default function Page() {
|
||||
return (
|
||||
<SpecializedOpsPage
|
||||
title="پیشپرداختها"
|
||||
description="ثبت پیشپرداخت به تأمینکنندگان."
|
||||
description="پیشپرداخت به تأمینکننده — روش پرداخت و صندوق/حساب بانکی را مشخص کنید."
|
||||
module="purchase"
|
||||
docType="prepayment"
|
||||
createLabel="پیشپرداخت جدید"
|
||||
@ -12,11 +12,35 @@ export default function Page() {
|
||||
{ key: "party", label: "تأمینکننده", kind: "party", module: "purchase" },
|
||||
{ key: "amount", label: "مبلغ", kind: "money" },
|
||||
{ key: "due_date", label: "سررسید", kind: "date" },
|
||||
{ key: "method", label: "روش پرداخت", kind: "select", options: [
|
||||
{ value: "cash", label: "نقد" },
|
||||
{ value: "bank", label: "بانک" },
|
||||
{
|
||||
key: "method",
|
||||
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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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<FormValues>({
|
||||
defaultValues: {
|
||||
|
||||
@ -4,7 +4,7 @@ export default function Page() {
|
||||
return (
|
||||
<SpecializedOpsPage
|
||||
title="پیشدریافتها"
|
||||
description="ثبت پیشدریافت از مشتریان با مبلغ و سررسید."
|
||||
description="پیشدریافت از مشتری — روش دریافت و صندوق/حساب بانکی را مشخص کنید."
|
||||
module="sales"
|
||||
docType="pre_receipt"
|
||||
createLabel="پیشدریافت جدید"
|
||||
@ -12,11 +12,35 @@ export default function Page() {
|
||||
{ key: "party", label: "مشتری", kind: "party", module: "sales" },
|
||||
{ key: "amount", label: "مبلغ", kind: "money" },
|
||||
{ key: "due_date", label: "سررسید", kind: "date" },
|
||||
{ key: "method", label: "روش دریافت", kind: "select", options: [
|
||||
{ value: "cash", label: "نقد" },
|
||||
{ value: "bank", label: "بانک" },
|
||||
{
|
||||
key: "method",
|
||||
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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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" ||
|
||||
|
||||
@ -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<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({
|
||||
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<string, string>();
|
||||
for (const b of banksQ.data ?? []) m.set(b.id, b.name);
|
||||
return m;
|
||||
}, [banksQ.data]);
|
||||
|
||||
const shape: Record<string, z.ZodTypeAny> = {
|
||||
number: z.string().optional(),
|
||||
doc_date: z.string().min(1, "تاریخ الزامی است"),
|
||||
@ -66,8 +105,13 @@ export function SpecializedOpsPage({
|
||||
};
|
||||
for (const f of fields) {
|
||||
if (f.kind === "party") continue;
|
||||
// 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<typeof schema>;
|
||||
|
||||
@ -87,6 +131,7 @@ export function SpecializedOpsPage({
|
||||
}, [fields]);
|
||||
|
||||
const form = useForm<Values>({ resolver: zodResolver(schema), defaultValues: defaults });
|
||||
const watched = form.watch() as Record<string, string>;
|
||||
|
||||
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<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> = {};
|
||||
for (const f of fields) {
|
||||
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;
|
||||
if (f.kind === "cash_box") {
|
||||
const box = (cashBoxesQ.data ?? []).find((c) => c.id === raw);
|
||||
if (box) meta.cash_box_name = box.name;
|
||||
}
|
||||
const amount = parseMoneyInput(String((v as Record<string, string>)[amountKey] ?? meta[amountKey] ?? "0")) || "0";
|
||||
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(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 <LoadingState />;
|
||||
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 (
|
||||
<div>
|
||||
<PageHeader
|
||||
@ -165,6 +253,11 @@ export function SpecializedOpsPage({
|
||||
{ key: "doc_date", header: "تاریخ", render: (r) => <JalaliDateText value={String(r.doc_date ?? "")} /> },
|
||||
{ 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({
|
||||
/>
|
||||
</FormField>
|
||||
{fields.map((f) => {
|
||||
if (!isShown(f, watched)) return null;
|
||||
if (f.kind === "party") {
|
||||
return (
|
||||
<FormField key={f.key} label={f.label} className="sm:col-span-2">
|
||||
@ -242,16 +336,77 @@ export function SpecializedOpsPage({
|
||||
control={form.control}
|
||||
name={f.key as keyof Values}
|
||||
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>
|
||||
);
|
||||
}
|
||||
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") {
|
||||
return (
|
||||
<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>
|
||||
{(f.options ?? []).map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
|
||||
@ -1707,10 +1707,30 @@ export const accountingApi = {
|
||||
|
||||
purchaseInventory: {
|
||||
listProfiles: (tenantId: string) =>
|
||||
request<{ id: string; name: string; is_default: boolean; is_active: boolean }[]>(
|
||||
"/api/v1/purchase-inventory/posting-profiles",
|
||||
{ tenantId }
|
||||
),
|
||||
request<
|
||||
{
|
||||
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: (
|
||||
tenantId: string,
|
||||
body: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user