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
|
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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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" ||
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user