From 97a363fc5311188ae6d97ce932e438e8c01cc6e0 Mon Sep 17 00:00:00 2001 From: Mortezakoohjani Date: Sun, 26 Jul 2026 19:51:32 +0330 Subject: [PATCH] Wire purchase returns, inventory issues, and GL transfers with auto vouchers. Goods receipts and warehouse receipts now capture party vs warehouse separately; purchase returns link to invoices and reduce AP. Co-authored-by: Cursor --- .../accounting/app/api/v1/operational.py | 28 ++ .../app/api/v1/purchase_inventory.py | 67 ++- .../accounting/app/api/v1/treasury.py | 96 +++- .../app/services/posting_policy_service.py | 10 +- .../services/purchase_inventory_service.py | 307 ++++++++++++- .../app/services/treasury_service.py | 46 ++ .../app/accounting/chart-of-accounts/page.tsx | 6 +- .../app/accounting/inventory/issues/page.tsx | 342 ++++++++++++++ .../inventory/receipts-issues/page.tsx | 4 +- .../purchase/goods-receipts/page.tsx | 4 +- .../app/accounting/purchase/returns/page.tsx | 416 +++++++++++++++++- .../accounting/settings/auto-posting/page.tsx | 4 +- .../accounting/BusinessDocsPage.tsx | 79 +++- .../accounting/OperationalDocumentPage.tsx | 215 ++++++++- .../accounting/SpecializedOpsPage.tsx | 15 +- .../components/accounting/WorkflowScreens.tsx | 2 +- frontend/lib/accounting-api.ts | 59 ++- frontend/lib/accounting-nav.ts | 3 +- 18 files changed, 1635 insertions(+), 68 deletions(-) create mode 100644 frontend/app/accounting/inventory/issues/page.tsx diff --git a/backend/services/accounting/app/api/v1/operational.py b/backend/services/accounting/app/api/v1/operational.py index 3da1751..44edf07 100644 --- a/backend/services/accounting/app/api/v1/operational.py +++ b/backend/services/accounting/app/api/v1/operational.py @@ -237,17 +237,45 @@ async def confirm_document( source_document_id=str(entity.id), amount=amount, actor_user_id=user.user_id, + party_id=entity.party_id, + party_name=entity.party_name, ) voucher_info = { "voucher_id": str(voucher.id), "voucher_number": voucher.voucher_number, } elif entity.module == "purchase" and entity.doc_type == "return": + import json as _json + + meta: dict = {} + if entity.meta_json: + try: + meta = _json.loads(entity.meta_json) + except Exception: + meta = {} + lines = meta.get("lines") or [] + lines_summary = None + if isinstance(lines, list) and lines: + bits = [] + for ln in lines[:5]: + if not isinstance(ln, dict): + continue + title = str(ln.get("title") or "") + qty = str(ln.get("qty") or "") + if title: + bits.append(f"{title}×{qty}" if qty else title) + if bits: + lines_summary = "، ".join(bits) voucher = await PurchaseInventoryAccountingService(db).post_purchase_return( tenant_id, source_document_id=str(entity.id), amount=amount, actor_user_id=user.user_id, + source_invoice_id=str(meta.get("source_invoice_id") or "") or None, + source_invoice_number=str(meta.get("source_invoice_number") or "") or None, + party_id=entity.party_id, + party_name=entity.party_name or (str(meta.get("party_name") or "") or None), + lines_summary=lines_summary, ) voucher_info = { "voucher_id": str(voucher.id), diff --git a/backend/services/accounting/app/api/v1/purchase_inventory.py b/backend/services/accounting/app/api/v1/purchase_inventory.py index ba220fc..e092a61 100644 --- a/backend/services/accounting/app/api/v1/purchase_inventory.py +++ b/backend/services/accounting/app/api/v1/purchase_inventory.py @@ -31,6 +31,11 @@ class GoodsReceiptRequest(BaseModel): source_document_id: str amount: Decimal profile_id: UUID | None = None + credit_mode: str = "supplier" # supplier | grni + credit_account_id: UUID | None = None + warehouse_name: str | None = None + source_note: str | None = None + party_name: str | None = None class ValuationRequest(BaseModel): @@ -96,6 +101,11 @@ async def post_goods_receipt( amount=body.amount, actor_user_id=user.user_id, profile_id=body.profile_id, + credit_mode=body.credit_mode, + credit_account_id=body.credit_account_id, + warehouse_name=body.warehouse_name, + source_note=body.source_note, + party_name=body.party_name, ) await db.commit() return {"voucher_id": str(voucher.id), "voucher_number": voucher.voucher_number, "status": voucher.status.value} @@ -141,9 +151,20 @@ async def post_purchase_invoice( } +class PurchaseReturnRequest(BaseModel): + source_document_id: str + amount: Decimal + profile_id: UUID | None = None + source_invoice_id: str | None = None + source_invoice_number: str | None = None + party_id: UUID | None = None + party_name: str | None = None + lines_summary: str | None = None + + @router.post("/post/purchase-return") async def post_purchase_return( - body: GoodsReceiptRequest, + body: PurchaseReturnRequest, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user), @@ -155,6 +176,50 @@ async def post_purchase_return( amount=body.amount, actor_user_id=user.user_id, profile_id=body.profile_id, + source_invoice_id=body.source_invoice_id, + source_invoice_number=body.source_invoice_number, + party_id=body.party_id, + party_name=body.party_name, + lines_summary=body.lines_summary, + ) + await db.commit() + return { + "voucher_id": str(voucher.id), + "voucher_number": voucher.voucher_number, + "status": voucher.status.value, + } + + +class InventoryIssueRequest(BaseModel): + source_document_id: str + amount: Decimal + reason: str = "expense" # expense | charity | consumption | barter + debit_account_id: UUID + credit_account_id: UUID | None = None + profile_id: UUID | None = None + barter_debit_account_id: UUID | None = None + barter_amount: Decimal | None = None + + +@router.post("/post/inventory-issue") +async def post_inventory_issue( + body: InventoryIssueRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + svc = PurchaseInventoryAccountingService(db) + voucher = await svc.post_inventory_issue( + tenant_id, + source_document_id=body.source_document_id, + amount=body.amount, + reason=body.reason, + debit_account_id=body.debit_account_id, + actor_user_id=user.user_id, + profile_id=body.profile_id, + credit_account_id=body.credit_account_id, + barter_debit_account_id=body.barter_debit_account_id, + barter_amount=body.barter_amount, ) await db.commit() return { diff --git a/backend/services/accounting/app/api/v1/treasury.py b/backend/services/accounting/app/api/v1/treasury.py index 1f06114..5f494a4 100644 --- a/backend/services/accounting/app/api/v1/treasury.py +++ b/backend/services/accounting/app/api/v1/treasury.py @@ -334,22 +334,114 @@ async def list_transfers( "to_type": t.to_type, "to_id": str(t.to_id), "description": t.description, + "voucher_id": str(t.voucher_id) if t.voucher_id else None, } for t in rows ] +async def _resolve_transfer_gl_account( + db: AsyncSession, + tenant_id: UUID, + side_type: str, + side_id: UUID, +) -> UUID: + from shared.exceptions import AppError + + if side_type == "account": + return side_id + if side_type == "cash": + box = await CashBoxRepo(db).get(tenant_id, side_id) + if box is None or box.account_id is None: + raise AppError( + "صندوق باید به حساب دفتر کل متصل باشد", + status_code=422, + error_code="cash_box_account_missing", + ) + return box.account_id + if side_type == "bank": + bank = await BankAccountRepo(db).get(tenant_id, side_id) + if bank is None or bank.account_id is None: + raise AppError( + "حساب بانکی باید به حساب دفتر کل متصل باشد", + status_code=422, + error_code="bank_account_gl_missing", + ) + return bank.account_id + raise AppError( + f"نوع مبدأ/مقصد نامعتبر است: {side_type} (account|cash|bank)", + status_code=422, + error_code="transfer_type_invalid", + ) + + @router.post("/transfers", status_code=status.HTTP_201_CREATED) async def create_transfer( body: TransferCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), - _user: CurrentUser = Depends(get_current_user), + user: CurrentUser = Depends(get_current_user), ): + """Transfer between cash/bank/any GL account — auto-posts Dr to / Cr from.""" + from app.services.posting_policy_service import PostingPolicyService + from app.services.source_posting_guard import SourcePostingGuard + entity = Transfer(tenant_id=tenant_id, **body.model_dump()) await TransferRepo(db).add(entity) + + from_gl = await _resolve_transfer_gl_account(db, tenant_id, body.from_type, body.from_id) + to_gl = await _resolve_transfer_gl_account(db, tenant_id, body.to_type, body.to_id) + + policy = await PostingPolicyService(db).get_policy(tenant_id) + voucher_id = None + voucher_number = None + # Transfers always need a GL document when accounts resolve — honor policy flag if present. + auto = bool((policy.get("auto_post") or {}).get("treasury_transfer", True)) + if auto: + guard = SourcePostingGuard(db) + await guard.assert_can_post( + tenant_id, + source_module="treasury", + source_document_type="transfer", + source_document_id=str(entity.id), + ) + svc = TreasuryService(db) + voucher = await svc.post_gl_transfer( + tenant_id, + amount=body.amount, + from_account_id=from_gl, + to_account_id=to_gl, + actor_user_id=user.user_id, + description=body.description or "انتقال بین حساب‌ها", + transaction_date=body.transfer_date, + reference=str(entity.id), + ) + entity.voucher_id = voucher.id + voucher_id = str(voucher.id) + voucher_number = voucher.voucher_number + await guard.mark_posted( + tenant_id, + voucher_id=voucher.id, + source_module="treasury", + source_document_type="transfer", + source_document_id=str(entity.id), + ) + # Update cash balances when sides are cash boxes + if body.from_type == "cash": + box = await CashBoxRepo(db).get(tenant_id, body.from_id) + if box is not None: + box.current_balance -= body.amount + if body.to_type == "cash": + box = await CashBoxRepo(db).get(tenant_id, body.to_id) + if box is not None: + box.current_balance += body.amount + await db.commit() - return {"id": str(entity.id)} + return { + "id": str(entity.id), + "voucher_id": voucher_id, + "voucher_number": voucher_number, + } @router.get("/receipts") diff --git a/backend/services/accounting/app/services/posting_policy_service.py b/backend/services/accounting/app/services/posting_policy_service.py index e4a06f8..92a7553 100644 --- a/backend/services/accounting/app/services/posting_policy_service.py +++ b/backend/services/accounting/app/services/posting_policy_service.py @@ -16,9 +16,11 @@ DEFAULT_POLICY: dict[str, str] = { "auto_post.sales.return": "false", "auto_post.purchase.goods_receipt": "false", "auto_post.purchase.invoice": "false", - "auto_post.purchase.return": "false", + "auto_post.purchase.return": "true", "auto_post.treasury.receipt": "true", "auto_post.treasury.payment": "true", + "auto_post.treasury.transfer": "true", + "auto_post.inventory.issue": "true", "auto_post.payroll": "false", "auto_post.assets.depreciation": "false", "policy.require_balanced": "true", @@ -61,6 +63,8 @@ class PostingPolicyService: "purchase_return": _as_bool(merged.get("auto_post.purchase.return")), "treasury_receipt": _as_bool(merged.get("auto_post.treasury.receipt"), True), "treasury_payment": _as_bool(merged.get("auto_post.treasury.payment"), True), + "treasury_transfer": _as_bool(merged.get("auto_post.treasury.transfer"), True), + "inventory_issue": _as_bool(merged.get("auto_post.inventory.issue"), True), "payroll": _as_bool(merged.get("auto_post.payroll")), "assets_depreciation": _as_bool(merged.get("auto_post.assets.depreciation")), } @@ -88,6 +92,8 @@ class PostingPolicyService: "purchase_return": "auto_post.purchase.return", "treasury_receipt": "auto_post.treasury.receipt", "treasury_payment": "auto_post.treasury.payment", + "treasury_transfer": "auto_post.treasury.transfer", + "inventory_issue": "auto_post.inventory.issue", "payroll": "auto_post.payroll", "assets_depreciation": "auto_post.assets.depreciation", } @@ -138,6 +144,8 @@ class PostingPolicyService: "purchase.return": "purchase_return", "treasury.receipt": "treasury_receipt", "treasury.payment": "treasury_payment", + "treasury.transfer": "treasury_transfer", + "inventory.issue": "inventory_issue", "payroll.payroll": "payroll", "assets.depreciation": "assets_depreciation", } diff --git a/backend/services/accounting/app/services/purchase_inventory_service.py b/backend/services/accounting/app/services/purchase_inventory_service.py index a00aa1d..fd64c15 100644 --- a/backend/services/accounting/app/services/purchase_inventory_service.py +++ b/backend/services/accounting/app/services/purchase_inventory_service.py @@ -111,10 +111,17 @@ class PurchaseInventoryAccountingService: amount: Decimal, actor_user_id: str, profile_id: UUID | None = None, + credit_mode: str = "supplier", + credit_account_id: UUID | None = None, + warehouse_name: str | None = None, + source_note: str | None = None, + party_name: str | None = None, ) -> Voucher: from datetime import date + from app.services.document_number_service import DocumentNumberService from app.services.source_posting_guard import SourcePostingGuard + from shared.exceptions import AppError guard = SourcePostingGuard(self.session) await guard.assert_can_post( @@ -129,28 +136,83 @@ class PurchaseInventoryAccountingService: if period is None: raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found") + if profile.inventory_account_id is None: + raise AppError( + "حساب موجودی در پروفایل خرید مشخص نیست", + status_code=422, + error_code="inventory_account_missing", + ) + + mode = (credit_mode or "grni").strip().lower() + if credit_account_id is not None: + credit_id = credit_account_id + credit_label = "حساب انتخاب‌شده" + elif mode == "supplier": + credit_id = profile.liability_account_id + credit_label = "تأمین‌کننده / حساب پرداختنی" + if credit_id is None: + raise AppError( + "حساب پرداختنی (بدهی تأمین‌کننده) در پروفایل خرید مشخص نیست", + status_code=422, + error_code="liability_account_missing", + ) + else: + credit_id = profile.grni_account_id or profile.liability_account_id + credit_label = "GRNI" if profile.grni_account_id else "تأمین‌کننده / حساب پرداختنی" + if credit_id is None: + raise AppError( + "حساب GRNI یا حساب پرداختنی در پروفایل خرید مشخص نیست", + status_code=422, + error_code="credit_account_missing", + ) + + parts = [f"رسید کالا {source_document_id}"] + if party_name: + parts.append(f"از: {party_name}") + if warehouse_name: + parts.append(f"انبار: {warehouse_name}") + parts.append(f"بستانکار: {credit_label}") + if source_note: + parts.append(source_note) + description = " — ".join(parts) + + number = await DocumentNumberService(self.session).allocate(tenant_id, "voucher") voucher = Voucher( tenant_id=tenant_id, fiscal_period_id=period.id, - voucher_number=f"GR-{source_document_id}", + voucher_number=number, voucher_date=date.today(), status=VoucherStatus.DRAFT, - description=f"Goods receipt {source_document_id}", + description=description, source_module="purchase", reference_number=source_document_id, created_by=actor_user_id, ) await self.voucher_repo.add(voucher) - if profile.inventory_account_id and profile.grni_account_id: - self.session.add(VoucherLine( - tenant_id=tenant_id, voucher_id=voucher.id, line_number=1, - account_id=profile.inventory_account_id, debit=amount, credit=Decimal("0"), - )) - self.session.add(VoucherLine( - tenant_id=tenant_id, voucher_id=voucher.id, line_number=2, - account_id=profile.grni_account_id, debit=Decimal("0"), credit=amount, - )) + self.session.add( + VoucherLine( + tenant_id=tenant_id, + voucher_id=voucher.id, + line_number=1, + account_id=profile.inventory_account_id, + debit=amount, + credit=Decimal("0"), + description=f"ورود به انبار{f' — {warehouse_name}' if warehouse_name else ''}", + ) + ) + self.session.add( + VoucherLine( + tenant_id=tenant_id, + voucher_id=voucher.id, + line_number=2, + account_id=credit_id, + debit=Decimal("0"), + credit=amount, + description=f"بستانکار {credit_label}" + + (f" — {party_name}" if party_name else ""), + ) + ) await self.session.flush() if await guard.should_post_immediately(tenant_id): voucher = await self.posting_engine.post_voucher( @@ -201,6 +263,8 @@ class PurchaseInventoryAccountingService: amount: Decimal, actor_user_id: str, profile_id: UUID | None = None, + party_id: UUID | None = None, + party_name: str | None = None, ) -> Voucher: from datetime import date @@ -231,13 +295,16 @@ class PurchaseInventoryAccountingService: ) number = await DocumentNumberService(self.session).allocate(tenant_id, "voucher") + desc = f"فاکتور خرید {source_document_id}" + if party_name: + desc = f"فاکتور خرید — تأمین‌کننده: {party_name}" voucher = Voucher( tenant_id=tenant_id, fiscal_period_id=period.id, voucher_number=number, voucher_date=date.today(), status=VoucherStatus.DRAFT, - description=f"Purchase invoice {source_document_id}", + description=desc, source_module="purchase", reference_number=source_document_id, created_by=actor_user_id, @@ -261,6 +328,7 @@ class PurchaseInventoryAccountingService: account_id=credit_account, debit=Decimal("0"), credit=amount, + description=f"بدهی تأمین‌کننده" + (f" — {party_name}" if party_name else ""), ) ) await self.session.flush() @@ -275,6 +343,8 @@ class PurchaseInventoryAccountingService: source_document_type=PurchaseDocumentType.SUPPLIER_INVOICE.value, source_document_id=source_document_id, ) + if party_id is not None: + await self._adjust_supplier_outstanding(tenant_id, party_id, amount) return voucher async def post_purchase_return( @@ -285,8 +355,16 @@ class PurchaseInventoryAccountingService: amount: Decimal, actor_user_id: str, profile_id: UUID | None = None, + source_invoice_id: str | None = None, + source_invoice_number: str | None = None, + party_id: UUID | None = None, + party_name: str | None = None, + lines_summary: str | None = None, ) -> Voucher: - """Reverse of supplier invoice: Dr AP liability / Cr inventory or GRNI.""" + """Purchase return: Dr AP liability (reduce debt) / Cr inventory. + + Must be linked to a purchase invoice so party and items are traceable. + """ from datetime import date from app.services.document_number_service import DocumentNumberService @@ -303,6 +381,9 @@ class PurchaseInventoryAccountingService: source_document_id=source_document_id, ) + if amount <= 0: + raise AppError("مبلغ مرجوعی باید بزرگ‌تر از صفر باشد", status_code=422, error_code="return_amount") + profile = await self._resolve_purchase_profile(tenant_id, profile_id) period = await self.period_repo.get_current(tenant_id) if period is None: @@ -317,6 +398,17 @@ class PurchaseInventoryAccountingService: error_code="purchase_profile_incomplete", ) + parts = ["مرجوعی خرید"] + if source_invoice_number: + parts.append(f"فاکتور {source_invoice_number}") + elif source_invoice_id: + parts.append(f"فاکتور {source_invoice_id}") + if party_name: + parts.append(f"تأمین‌کننده: {party_name}") + if lines_summary: + parts.append(lines_summary) + description = " — ".join(parts) + number = await DocumentNumberService(self.session).allocate(tenant_id, "voucher") voucher = Voucher( tenant_id=tenant_id, @@ -324,9 +416,9 @@ class PurchaseInventoryAccountingService: voucher_number=number, voucher_date=date.today(), status=VoucherStatus.DRAFT, - description=f"Purchase return {source_document_id}", + description=description, source_module="purchase", - reference_number=source_document_id, + reference_number=source_invoice_id or source_document_id, created_by=actor_user_id, ) await self.voucher_repo.add(voucher) @@ -338,6 +430,8 @@ class PurchaseInventoryAccountingService: account_id=debit_account, debit=amount, credit=Decimal("0"), + description=f"کاهش بدهی تأمین‌کننده" + + (f" — {party_name}" if party_name else ""), ) ) self.session.add( @@ -348,6 +442,8 @@ class PurchaseInventoryAccountingService: account_id=credit_account, debit=Decimal("0"), credit=amount, + description="خروج موجودی بابت مرجوعی" + + (f" — {lines_summary}" if lines_summary else ""), ) ) await self.session.flush() @@ -362,6 +458,187 @@ class PurchaseInventoryAccountingService: source_document_type=doc_type_value, source_document_id=source_document_id, ) + + # Reduce supplier payable balance (our debt to them goes down) + if party_id is not None: + await self._adjust_supplier_outstanding(tenant_id, party_id, -amount) + return voucher + + async def _adjust_supplier_outstanding( + self, tenant_id: UUID, supplier_id: UUID, delta: Decimal + ) -> None: + from sqlalchemy import select + + from app.models.receivable_payable import SupplierAccount + + result = await self.session.execute( + select(SupplierAccount).where( + SupplierAccount.tenant_id == tenant_id, + SupplierAccount.id == supplier_id, + ) + ) + supplier = result.scalar_one_or_none() + if supplier is None: + return + supplier.outstanding_balance = (supplier.outstanding_balance or Decimal("0")) + delta + if supplier.outstanding_balance < 0: + supplier.outstanding_balance = Decimal("0") + + async def post_inventory_issue( + self, + tenant_id: UUID, + *, + source_document_id: str, + amount: Decimal, + reason: str, + debit_account_id: UUID, + actor_user_id: str, + profile_id: UUID | None = None, + credit_account_id: UUID | None = None, + barter_debit_account_id: UUID | None = None, + barter_amount: Decimal | None = None, + ): + """Issue inventory to expense/charity/consumption/barter via Posting Engine. + + Standard: Dr expense (or barter inventory) / Cr inventory. + Barter optional second pair when receiving another item of different value. + """ + from datetime import date + + from app.services.document_number_service import DocumentNumberService + from app.services.source_posting_guard import SourcePostingGuard + from shared.exceptions import AppError + + guard = SourcePostingGuard(self.session) + await guard.assert_can_post( + tenant_id, + source_module="inventory", + source_document_type=f"issue_{reason}", + source_document_id=source_document_id, + ) + + profile = await self._resolve_purchase_profile(tenant_id, profile_id) + inventory_credit = credit_account_id or profile.inventory_account_id + if inventory_credit is None: + raise AppError( + "حساب موجودی برای خروج کالا مشخص نیست (پروفایل خرید یا انتخاب دستی)", + status_code=422, + error_code="inventory_account_missing", + ) + if amount <= 0: + raise AppError("مبلغ خروج باید بزرگ‌تر از صفر باشد", status_code=422, error_code="issue_amount") + + period = await self.period_repo.get_current(tenant_id) + if period is None: + raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found") + + reason_fa = { + "expense": "هزینه", + "charity": "خیرات/اهدایی", + "consumption": "مصرف داخلی", + "barter": "تهاتر/معاوضه", + }.get(reason, reason) + + number = await DocumentNumberService(self.session).allocate(tenant_id, "voucher") + voucher = Voucher( + tenant_id=tenant_id, + fiscal_period_id=period.id, + voucher_number=number, + voucher_date=date.today(), + status=VoucherStatus.DRAFT, + description=f"خروج موجودی — {reason_fa} — {source_document_id}", + source_module="inventory", + reference_number=source_document_id, + created_by=actor_user_id, + ) + await self.voucher_repo.add(voucher) + + if reason == "barter" and barter_debit_account_id and barter_amount and barter_amount > 0: + # Give stock (Cr inventory), receive other stock/value (Dr), difference to expense/income account + self.session.add( + VoucherLine( + tenant_id=tenant_id, + voucher_id=voucher.id, + line_number=1, + account_id=inventory_credit, + debit=Decimal("0"), + credit=amount, + description=f"خروج کالا — {reason_fa}", + ) + ) + self.session.add( + VoucherLine( + tenant_id=tenant_id, + voucher_id=voucher.id, + line_number=2, + account_id=barter_debit_account_id, + debit=barter_amount, + credit=Decimal("0"), + description="کالای/ارزش دریافتی تهاتر", + ) + ) + diff = amount - barter_amount + if diff > 0: + self.session.add( + VoucherLine( + tenant_id=tenant_id, + voucher_id=voucher.id, + line_number=3, + account_id=debit_account_id, + debit=diff, + credit=Decimal("0"), + description="مابه‌التفاوت تهاتر (هزینه)", + ) + ) + elif diff < 0: + self.session.add( + VoucherLine( + tenant_id=tenant_id, + voucher_id=voucher.id, + line_number=3, + account_id=debit_account_id, + debit=Decimal("0"), + credit=-diff, + description="مابه‌التفاوت تهاتر (درآمد/تخفیف)", + ) + ) + else: + # expense / charity / consumption: Dr target account, Cr inventory + self.session.add( + VoucherLine( + tenant_id=tenant_id, + voucher_id=voucher.id, + line_number=1, + account_id=debit_account_id, + debit=amount, + credit=Decimal("0"), + description=reason_fa, + ) + ) + self.session.add( + VoucherLine( + tenant_id=tenant_id, + voucher_id=voucher.id, + line_number=2, + account_id=inventory_credit, + debit=Decimal("0"), + credit=amount, + description=reason_fa, + ) + ) + + await self.session.flush() + if await guard.should_post_immediately(tenant_id): + voucher = await self.posting_engine.post_voucher( + tenant_id, voucher.id, actor_user_id=actor_user_id, source_module="inventory" + ) + await guard.mark_posted( + tenant_id, + voucher_id=voucher.id, + source_module="inventory", + source_document_type=f"issue_{reason}", + source_document_id=source_document_id, + ) return voucher async def record_valuation( diff --git a/backend/services/accounting/app/services/treasury_service.py b/backend/services/accounting/app/services/treasury_service.py index 34f71bc..63733c9 100644 --- a/backend/services/accounting/app/services/treasury_service.py +++ b/backend/services/accounting/app/services/treasury_service.py @@ -132,6 +132,52 @@ class TreasuryService: cash_box.current_balance -= amount return tx + async def post_gl_transfer( + self, + tenant_id: UUID, + *, + amount: Decimal, + from_account_id: UUID, + to_account_id: UUID, + actor_user_id: str, + description: str, + transaction_date: date | None = None, + reference: str | None = None, + ) -> Voucher: + """Any GL → any GL: Dr destination (to), Cr source (from).""" + from app.services.document_number_service import DocumentNumberService + from app.services.source_posting_guard import SourcePostingGuard + from shared.exceptions import AppError + + if from_account_id == to_account_id: + raise AppError( + "حساب مبدأ و مقصد نباید یکسان باشند", + status_code=422, + error_code="transfer_same_account", + ) + if amount <= 0: + raise AppError("مبلغ انتقال باید بزرگ‌تر از صفر باشد", status_code=422, error_code="transfer_amount") + + period = await self.period_repo.get_current(tenant_id) + if period is None: + raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found") + + number = await DocumentNumberService(self.session).allocate(tenant_id, "voucher") + return await self._create_treasury_voucher( + tenant_id, + period.id, + number, + transaction_date or date.today(), + to_account_id, + from_account_id, + amount, + description or "انتقال بین حساب‌ها", + actor_user_id, + "treasury", + post_immediately=await SourcePostingGuard(self.session).should_post_immediately(tenant_id), + reference_number=reference, + ) + async def post_receipt_payment_voucher( self, tenant_id: UUID, diff --git a/frontend/app/accounting/chart-of-accounts/page.tsx b/frontend/app/accounting/chart-of-accounts/page.tsx index 3f306fc..a7aa766 100644 --- a/frontend/app/accounting/chart-of-accounts/page.tsx +++ b/frontend/app/accounting/chart-of-accounts/page.tsx @@ -49,6 +49,8 @@ const accountSchema = z.object({ }); type ChartForm = z.infer; +const editChartSchema = chartSchema.omit({ code: true }).extend({ code: z.string().optional() }); +type EditChartForm = z.infer; type AccountForm = z.infer; const ACCOUNT_TYPES = [ @@ -112,8 +114,8 @@ export default function ChartOfAccountsPage() { description: "", }, }); - const editChartForm = useForm({ - resolver: zodResolver(chartSchema.omit({ code: true }).extend({ code: z.string().optional() })), + const editChartForm = useForm({ + resolver: zodResolver(editChartSchema), }); const editAccountForm = useForm({ defaultValues: { name: "", status: "active", description: "", is_postable: true }, diff --git a/frontend/app/accounting/inventory/issues/page.tsx b/frontend/app/accounting/inventory/issues/page.tsx new file mode 100644 index 0000000..849676e --- /dev/null +++ b/frontend/app/accounting/inventory/issues/page.tsx @@ -0,0 +1,342 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Plus } from "lucide-react"; +import { accountingApi } from "@/lib/accounting-api"; +import { useTenantId } from "@/hooks/useTenantId"; +import { formatMoney, parseMoneyInput, toRialInteger } from "@/lib/utils"; +import { + PageHeader, + Button, + Dialog, + Input, + DataTable, + EmptyState, + LoadingState, + ErrorState, + FormField, + MoneyInput, + DatePicker, + JalaliDateText, + Select, + Badge, +} from "@/components/ds"; +import { ItemCombobox } from "@/components/accounting/EntityCombobox"; + +type FormValues = { + number: string; + doc_date: string; + reason: "expense" | "charity" | "consumption" | "barter"; + item_label: string; + item_id: string; + qty: string; + unit_cost: string; + warehouse_id: string; + debit_account_id: string; + barter_debit_account_id: string; + barter_amount: string; + description: string; +}; + +const REASON_FA: Record = { + expense: "هزینه", + charity: "خیرات / اهدایی", + consumption: "مصرف داخلی (آبدارخانه و …)", + barter: "تهاتر / معاوضه کالا", +}; + +export default function InventoryIssuesPage() { + const { tenantId } = useTenantId(); + const qc = useQueryClient(); + const [open, setOpen] = useState(false); + + const listQ = useQuery({ + queryKey: ["accounting", tenantId, "ops-docs", "inventory", "issue"], + queryFn: () => accountingApi.ops.listDocuments(tenantId!, "inventory", "issue"), + enabled: !!tenantId, + }); + const accountsQ = useQuery({ + queryKey: ["accounting", tenantId, "accounts"], + queryFn: () => accountingApi.accounts.list(tenantId!), + enabled: !!tenantId, + }); + const warehousesQ = useQuery({ + queryKey: ["accounting", tenantId, "warehouses"], + queryFn: () => accountingApi.ops.listWarehouses(tenantId!), + enabled: !!tenantId, + }); + const policyQ = useQuery({ + queryKey: ["accounting", tenantId, "posting-policy"], + queryFn: () => accountingApi.setup.getPostingPolicy(tenantId!), + enabled: !!tenantId, + }); + + const form = useForm({ + defaultValues: { + number: "", + doc_date: new Date().toISOString().slice(0, 10), + reason: "expense", + item_label: "", + item_id: "", + qty: "1", + unit_cost: "0", + warehouse_id: "", + debit_account_id: "", + barter_debit_account_id: "", + barter_amount: "0", + description: "", + }, + }); + + const reason = form.watch("reason"); + const amount = useMemo(() => { + const q = Number(toRialInteger(form.watch("qty")) || "0"); + const c = Number(toRialInteger(form.watch("unit_cost")) || "0"); + return String(q * c); + }, [form.watch("qty"), form.watch("unit_cost")]); + + const postable = (accountsQ.data ?? []).filter((a) => a.is_postable); + const autoIssue = Boolean(policyQ.data?.auto_post?.inventory_issue ?? true); + + const createM = useMutation({ + mutationFn: async (d: FormValues) => { + const amt = parseMoneyInput(amount) || "0"; + if (Number(amt) <= 0) throw new Error("مبلغ خروج باید بزرگ‌تر از صفر باشد"); + if (!d.debit_account_id) throw new Error("حساب هزینه/مقصد الزامی است"); + if (d.reason === "barter" && !d.barter_debit_account_id) { + throw new Error("برای تهاتر، حساب کالای دریافتی را انتخاب کنید"); + } + + const doc = await accountingApi.ops.createDocument(tenantId!, { + module: "inventory", + doc_type: "issue", + number: d.number?.trim() || undefined, + doc_date: d.doc_date, + amount: amt, + description: d.description || REASON_FA[d.reason], + status: "draft", + meta_json: JSON.stringify({ + reason: d.reason, + item_id: d.item_id, + item_label: d.item_label, + qty: d.qty, + unit_cost: d.unit_cost, + warehouse_id: d.warehouse_id || undefined, + debit_account_id: d.debit_account_id, + barter_debit_account_id: d.barter_debit_account_id || undefined, + barter_amount: d.barter_amount || undefined, + }), + }); + + if (autoIssue) { + const posted = await accountingApi.purchaseInventory.postInventoryIssue(tenantId!, { + source_document_id: String(doc.id), + amount: amt, + reason: d.reason, + debit_account_id: d.debit_account_id, + barter_debit_account_id: d.barter_debit_account_id || undefined, + barter_amount: + d.reason === "barter" ? parseMoneyInput(d.barter_amount) || amt : undefined, + }); + await accountingApi.ops.confirmDocument(tenantId!, String(doc.id)).catch(() => null); + return posted; + } + return doc; + }, + onSuccess: async (res) => { + const vn = + res && typeof res === "object" && "voucher_number" in res + ? String((res as { voucher_number?: string }).voucher_number || "") + : ""; + toast.success(vn ? `خروج ثبت و سند ${vn} صادر شد` : "خروج کالا ثبت شد"); + setOpen(false); + form.reset(); + await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ops-docs", "inventory", "issue"] }); + await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "vouchers"] }); + }, + onError: (e: Error) => toast.error(e.message), + }); + + if (!tenantId || listQ.isLoading) return ; + if (listQ.error) return listQ.refetch()} />; + + return ( +
+ setOpen(true)}> + + خروج کالای جدید + + } + /> + + , + }, + { + key: "reason", + header: "نوع", + render: (r) => { + try { + const meta = JSON.parse(String(r.meta_json || "{}")) as { reason?: string }; + return REASON_FA[meta.reason || ""] || meta.reason || "—"; + } catch { + return "—"; + } + }, + }, + { + key: "amount", + header: "مبلغ", + render: (r) => formatMoney(String(r.amount ?? "0")), + }, + { + key: "status", + header: "وضعیت", + render: (r) => {String(r.status)}, + }, + { key: "description", header: "شرح" }, + ]} + rows={(listQ.data ?? []) as unknown as Record[]} + empty={} + /> + + setOpen(false)} title="خروج / هزینه‌کرد کالا"> +
createM.mutate(d))}> + + + + + ( + + )} + /> + +
+ + { + form.setValue("item_id", item.id); + form.setValue("item_label", `${item.code} — ${item.name}`); + form.setValue("unit_cost", parseMoneyInput(item.unit_cost) || "0"); + }} + onClear={() => { + form.setValue("item_id", ""); + form.setValue("item_label", ""); + }} + /> + +
+ + + + + + + + + + + + +
+ + + +
+ {reason === "barter" ? ( + <> +
+ + + +
+ + + + + ) : null} +
+ + + +
+

+ {autoIssue + ? "با ذخیره، سند حسابداری خودکار از Posting Engine صادر می‌شود." + : "ثبت خودکار خاموش است — فقط سند عملیاتی ذخیره می‌شود (تنظیمات → اسناد اتوماتیک)."} +

+
+ + +
+
+
+
+ ); +} diff --git a/frontend/app/accounting/inventory/receipts-issues/page.tsx b/frontend/app/accounting/inventory/receipts-issues/page.tsx index 9c1f6f7..7891869 100644 --- a/frontend/app/accounting/inventory/receipts-issues/page.tsx +++ b/frontend/app/accounting/inventory/receipts-issues/page.tsx @@ -6,11 +6,13 @@ export default function Page() { return ( ); } diff --git a/frontend/app/accounting/purchase/goods-receipts/page.tsx b/frontend/app/accounting/purchase/goods-receipts/page.tsx index cb5a8d2..0bf0755 100644 --- a/frontend/app/accounting/purchase/goods-receipts/page.tsx +++ b/frontend/app/accounting/purchase/goods-receipts/page.tsx @@ -6,12 +6,14 @@ export default function Page() { return ( ); } diff --git a/frontend/app/accounting/purchase/returns/page.tsx b/frontend/app/accounting/purchase/returns/page.tsx index 27bdac1..ea82681 100644 --- a/frontend/app/accounting/purchase/returns/page.tsx +++ b/frontend/app/accounting/purchase/returns/page.tsx @@ -1,17 +1,411 @@ "use client"; -import { OperationalDocumentPage } from "@/components/accounting/OperationalDocumentPage"; +import { useMemo, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Plus } from "lucide-react"; +import { accountingApi } from "@/lib/accounting-api"; +import { useTenantId } from "@/hooks/useTenantId"; +import { formatMoney, parseMoneyInput } from "@/lib/utils"; +import { + PageHeader, + Button, + Dialog, + Input, + DataTable, + EmptyState, + LoadingState, + ErrorState, + FormField, + DatePicker, + JalaliDateText, + Select, + Badge, +} from "@/components/ds"; + +type InvoiceLine = { + title: string; + qty: string; + unit_price: string; + note?: string; + item_id?: string; +}; + +type ReturnLine = InvoiceLine & { + return_qty: string; + selected: boolean; + invoice_line_index: number; +}; + +type FormValues = { + number: string; + doc_date: string; + source_invoice_id: string; + description: string; +}; + +function parseMeta(meta: unknown): { + lines?: InvoiceLine[]; + source_invoice_id?: string; + source_invoice_number?: string; + accounting_voucher?: { voucher_number?: string }; +} { + if (!meta) return {}; + try { + const raw = typeof meta === "string" ? JSON.parse(meta) : meta; + return (raw && typeof raw === "object" ? raw : {}) as ReturnType; + } catch { + return {}; + } +} + +function lineAmount(qty: string, unitPrice: string): number { + return Number(parseMoneyInput(qty) || "0") * Number(parseMoneyInput(unitPrice) || "0"); +} + +export default function PurchaseReturnsPage() { + const { tenantId } = useTenantId(); + const qc = useQueryClient(); + const [open, setOpen] = useState(false); + const [returnLines, setReturnLines] = useState([]); + + const returnsQ = useQuery({ + queryKey: ["accounting", tenantId, "ops-docs", "purchase", "return"], + queryFn: () => accountingApi.ops.listDocuments(tenantId!, "purchase", "return"), + enabled: !!tenantId, + }); + const invoicesQ = useQuery({ + queryKey: ["accounting", tenantId, "ops-docs", "purchase", "invoice"], + queryFn: () => accountingApi.ops.listDocuments(tenantId!, "purchase", "invoice"), + enabled: !!tenantId, + }); + const policyQ = useQuery({ + queryKey: ["accounting", tenantId, "posting-policy"], + queryFn: () => accountingApi.setup.getPostingPolicy(tenantId!), + enabled: !!tenantId, + }); + + const form = useForm({ + defaultValues: { + number: "", + doc_date: new Date().toISOString().slice(0, 10), + source_invoice_id: "", + description: "", + }, + }); + + const invoiceId = form.watch("source_invoice_id"); + const selectedInvoice = useMemo( + () => (invoicesQ.data ?? []).find((i) => i.id === invoiceId), + [invoicesQ.data, invoiceId] + ); + + const returnTotal = useMemo( + () => + returnLines + .filter((l) => l.selected) + .reduce((sum, l) => sum + lineAmount(l.return_qty, l.unit_price), 0), + [returnLines] + ); + + const autoReturn = Boolean(policyQ.data?.auto_post?.purchase_return ?? true); + + const onInvoiceChange = (id: string) => { + form.setValue("source_invoice_id", id); + const inv = (invoicesQ.data ?? []).find((i) => i.id === id); + if (!inv) { + setReturnLines([]); + return; + } + const meta = parseMeta(inv.meta_json); + const lines = meta.lines ?? []; + if (!lines.length) { + // Invoice without line items: treat whole amount as one returnable row + setReturnLines([ + { + title: inv.description || `فاکتور ${inv.number}`, + qty: "1", + unit_price: String(inv.amount || "0"), + return_qty: "1", + selected: true, + invoice_line_index: 0, + }, + ]); + return; + } + setReturnLines( + lines.map((ln, idx) => ({ + title: ln.title || "کالا", + qty: ln.qty || "1", + unit_price: ln.unit_price || "0", + note: ln.note, + item_id: ln.item_id, + return_qty: ln.qty || "1", + selected: true, + invoice_line_index: idx, + })) + ); + }; + + const createM = useMutation({ + mutationFn: async (d: FormValues) => { + if (!selectedInvoice) throw new Error("فاکتور خرید را انتخاب کنید"); + const selected = returnLines.filter((l) => l.selected && Number(parseMoneyInput(l.return_qty) || "0") > 0); + if (!selected.length) throw new Error("حداقل یک کالا با تعداد مرجوعی مشخص کنید"); + + for (const ln of selected) { + const maxQty = Number(parseMoneyInput(ln.qty) || "0"); + const retQty = Number(parseMoneyInput(ln.return_qty) || "0"); + if (retQty > maxQty) { + throw new Error(`تعداد مرجوعی «${ln.title}» بیشتر از فاکتور است`); + } + } + + const amount = String(returnTotal); + if (Number(amount) <= 0) throw new Error("مبلغ مرجوعی باید بزرگ‌تر از صفر باشد"); + + const linesPayload = selected.map((l) => ({ + title: l.title, + qty: parseMoneyInput(l.return_qty) || "0", + unit_price: parseMoneyInput(l.unit_price) || "0", + note: l.note, + item_id: l.item_id, + invoice_line_index: l.invoice_line_index, + invoice_qty: l.qty, + })); + + const doc = await accountingApi.ops.createDocument(tenantId!, { + module: "purchase", + doc_type: "return", + number: d.number?.trim() || undefined, + doc_date: d.doc_date, + party_name: selectedInvoice.party_name || undefined, + party_id: selectedInvoice.party_id || undefined, + amount, + description: + d.description || + `مرجوعی از فاکتور ${selectedInvoice.number}` + + (selectedInvoice.party_name ? ` — ${selectedInvoice.party_name}` : ""), + status: "draft", + meta_json: JSON.stringify({ + source_invoice_id: selectedInvoice.id, + source_invoice_number: selectedInvoice.number, + party_name: selectedInvoice.party_name, + party_id: selectedInvoice.party_id, + lines: linesPayload, + }), + }); + + if (autoReturn) { + // Confirm triggers auto GL (Dr AP / Cr inventory) + supplier balance decrease + const confirmed = await accountingApi.ops.confirmDocument(tenantId!, String(doc.id)); + return confirmed; + } + return doc; + }, + onSuccess: async (res) => { + const meta = + res && typeof res === "object" && "meta_json" in res + ? parseMeta((res as { meta_json?: string | null }).meta_json) + : {}; + const vn = meta.accounting_voucher?.voucher_number || ""; + toast.success( + vn + ? `مرجوعی ثبت شد — سند ${vn} (کاهش بدهی تأمین‌کننده)` + : "مرجوعی ثبت شد" + ); + setOpen(false); + form.reset(); + setReturnLines([]); + await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ops-docs", "purchase", "return"] }); + await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "vouchers"] }); + await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "suppliers"] }); + }, + onError: (e: Error) => toast.error(e.message), + }); + + if (!tenantId || returnsQ.isLoading) return ; + if (returnsQ.error) return returnsQ.refetch()} />; + + const invoices = (invoicesQ.data ?? []).filter((i) => i.status !== "cancelled"); -export default function Page() { return ( - +
+ setOpen(true)}> + + مرجوعی جدید + + } + /> + + , + }, + { + key: "invoice", + header: "فاکتور مبدأ", + render: (r) => parseMeta(r.meta_json).source_invoice_number || "—", + }, + { key: "party_name", header: "تأمین‌کننده" }, + { + key: "items", + header: "کالاها", + render: (r) => { + const lines = parseMeta(r.meta_json).lines ?? []; + if (!lines.length) return "—"; + return lines.map((l) => `${l.title}×${l.qty}`).join("، "); + }, + }, + { + key: "amount", + header: "مبلغ", + render: (r) => formatMoney(String(r.amount ?? "0")), + }, + { + key: "status", + header: "وضعیت", + render: (r) => ( + + {String(r.status)} + + ), + }, + { + key: "voucher", + header: "سند GL", + render: (r) => parseMeta(r.meta_json).accounting_voucher?.voucher_number || "—", + }, + ]} + rows={(returnsQ.data ?? []) as unknown as Record[]} + empty={} + /> + + setOpen(false)} title="مرجوعی خرید از فاکتور" size="xl"> +
createM.mutate(d))}> + + + + + ( + + )} + /> + + + {selectedInvoice ? ( +
+
+ تأمین‌کننده: {selectedInvoice.party_name || "—"} +
+
+ فاکتور {selectedInvoice.number} — مبلغ فاکتور {formatMoney(String(selectedInvoice.amount))} +
+
+ ) : null} + + {returnLines.length > 0 ? ( +
+ + + + + + + + + + + + + {returnLines.map((ln, idx) => ( + + + + + + + + + ))} + +
انتخابکالا (از فاکتور)تعداد فاکتورتعداد مرجوعیفیجمع
+ { + const next = [...returnLines]; + next[idx] = { ...ln, selected: e.target.checked }; + setReturnLines(next); + }} + /> + {ln.title} + {ln.qty} + + { + const next = [...returnLines]; + next[idx] = { ...ln, return_qty: e.target.value }; + setReturnLines(next); + }} + /> + + {formatMoney(parseMoneyInput(ln.unit_price) || "0")} + + {ln.selected + ? formatMoney(String(lineAmount(ln.return_qty, ln.unit_price))) + : "—"} +
+
+ ) : null} + + + + + + + +
+ + + +
+ +
+ + +
+
+
+
); } diff --git a/frontend/app/accounting/settings/auto-posting/page.tsx b/frontend/app/accounting/settings/auto-posting/page.tsx index fef4c0f..07c0c8e 100644 --- a/frontend/app/accounting/settings/auto-posting/page.tsx +++ b/frontend/app/accounting/settings/auto-posting/page.tsx @@ -23,9 +23,11 @@ const AUTO_KEYS: { key: string; label: string; hint: string }[] = [ { key: "sales_return", label: "مرجوعی فروش", hint: "ثبت خودکار برگشت فروش در دفتر" }, { key: "purchase_goods_receipt", label: "رسید کالا", hint: "Dr موجودی / Cr GRNI هنگام تأیید رسید" }, { key: "purchase_invoice", label: "فاکتور خرید", hint: "ثبت بدهی تأمین‌کننده هنگام تأیید فاکتور" }, - { key: "purchase_return", label: "مرجوعی خرید", hint: "ثبت خودکار مرجوعی خرید" }, + { key: "purchase_return", label: "مرجوعی خرید", hint: "وصل به فاکتور خرید — کاهش بدهی تأمین‌کننده (Dr AP / Cr موجودی)" }, { key: "treasury_receipt", label: "دریافت خزانه", hint: "دریافت نقدی/بانکی همیشه به Posting Engine" }, { key: "treasury_payment", label: "پرداخت خزانه", hint: "پرداخت نقدی/بانکی همیشه به Posting Engine" }, + { key: "treasury_transfer", label: "انتقال بین حساب‌ها", hint: "از هر حساب به هر حساب — سند اتومات Dr مقصد / Cr مبدأ" }, + { key: "inventory_issue", label: "خروج / هزینه‌کرد کالا", hint: "خیرات، مصرف، هزینه، تهاتر با سند اتومات" }, { key: "payroll", label: "حقوق و دستمزد", hint: "پس از محاسبه/تأیید حقوق" }, { key: "assets_depreciation", label: "استهلاک دارایی", hint: "ثبت خودکار استهلاک دوره‌ای" }, ]; diff --git a/frontend/components/accounting/BusinessDocsPage.tsx b/frontend/components/accounting/BusinessDocsPage.tsx index 1a8d4a9..254ef32 100644 --- a/frontend/components/accounting/BusinessDocsPage.tsx +++ b/frontend/components/accounting/BusinessDocsPage.tsx @@ -1005,6 +1005,11 @@ export function TransfersPage() { queryFn: () => accountingApi.treasury.listBankAccounts(tenantId!), enabled: !!tenantId, }); + const accountsQ = useQuery({ + queryKey: ["accounting", tenantId, "accounts"], + queryFn: () => accountingApi.accounts.list(tenantId!), + enabled: !!tenantId, + }); const listQ = useQuery({ queryKey: ["accounting", tenantId, "transfers"], queryFn: () => accountingApi.treasury.listTransfers(tenantId!), @@ -1030,6 +1035,12 @@ export function TransfersPage() { }) => { const [from_type, from_id] = d.from_key.split(":"); const [to_type, to_id] = d.to_key.split(":"); + if (!from_type || !from_id || !to_type || !to_id) { + throw new Error("حساب مبدأ و مقصد را انتخاب کنید"); + } + if (d.from_key === d.to_key) { + throw new Error("مبدأ و مقصد نباید یکسان باشند"); + } return accountingApi.treasury.createTransfer(tenantId!, { transfer_date: d.transfer_date, amount: d.amount, @@ -1040,11 +1051,16 @@ export function TransfersPage() { description: d.description, }); }, - onSuccess: async () => { - toast.success("انتقال ثبت شد"); + onSuccess: async (res) => { + const vn = + res && typeof res === "object" && "voucher_number" in res + ? String((res as { voucher_number?: string | null }).voucher_number || "") + : ""; + toast.success(vn ? `انتقال ثبت و سند ${vn} صادر شد` : "انتقال ثبت شد"); setOpen(false); form.reset(); await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "transfers"] }); + await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "vouchers"] }); }, onError: (e: Error) => toast.error(e.message), }); @@ -1052,19 +1068,38 @@ export function TransfersPage() { if (!tenantId || listQ.isLoading) return ; if (listQ.error) return listQ.refetch()} />; + const labelMap = new Map(); const accountOptions = [ - ...(cashBoxesQ.data ?? []).map((c) => ({ value: `cash:${c.id}`, label: `صندوق: ${c.name}` })), - ...(bankAccountsQ.data ?? []).map((b) => ({ - value: `bank:${b.id}`, - label: `بانک: ${b.account_number}`, - })), + ...(cashBoxesQ.data ?? []).map((c) => { + const value = `cash:${c.id}`; + const label = `صندوق: ${c.name}`; + labelMap.set(value, label); + return { value, label }; + }), + ...(bankAccountsQ.data ?? []).map((b) => { + const value = `bank:${b.id}`; + const label = `بانک: ${b.account_number}`; + labelMap.set(value, label); + return { value, label }; + }), + ...(accountsQ.data ?? []) + .filter((a) => a.is_postable) + .map((a) => { + const value = `account:${a.id}`; + const label = `حساب: ${a.code} — ${a.name}`; + labelMap.set(value, label); + return { value, label }; + }), ]; + const sideLabel = (type: unknown, id: unknown) => + labelMap.get(`${String(type)}:${String(id)}`) || `${String(type)}`; + return (
setOpen(true)}> @@ -1084,8 +1119,21 @@ export function TransfersPage() { header: "مبلغ", render: (r) => formatMoney(String(r.amount)), }, - { key: "from_type", header: "از" }, - { key: "to_type", header: "به" }, + { + key: "from_id", + header: "از", + render: (r) => sideLabel(r.from_type, r.from_id), + }, + { + key: "to_id", + header: "به", + render: (r) => sideLabel(r.to_type, r.to_id), + }, + { + key: "voucher_id", + header: "سند GL", + render: (r) => (r.voucher_id ? صادر شد : "—"), + }, { key: "description", header: "توضیح" }, ]} rows={(listQ.data ?? []) as unknown as Record[]} @@ -1105,7 +1153,7 @@ export function TransfersPage() { - + - + +

+ با ذخیره، سند حسابداری خودکار ساخته می‌شود — نیازی به ثبت دستی سند نیست. +

diff --git a/frontend/components/accounting/OperationalDocumentPage.tsx b/frontend/components/accounting/OperationalDocumentPage.tsx index 29e562c..86176d7 100644 --- a/frontend/components/accounting/OperationalDocumentPage.tsx +++ b/frontend/components/accounting/OperationalDocumentPage.tsx @@ -48,6 +48,11 @@ const formSchema = z.object({ party_id: z.string().optional(), description: z.string().optional(), amount: z.string().optional(), + warehouse_id: z.string().optional(), + warehouse_name: z.string().optional(), + credit_mode: z.string().optional(), + source_note: z.string().optional(), + movement_type: z.string().optional(), lines: z .array( z.object({ @@ -110,16 +115,37 @@ function sumLines(lines: LineValues[] | undefined): string { return String(total); } -function parseMeta(meta: unknown): { lines?: LineValues[] } { +function parseMeta(meta: unknown): { + lines?: LineValues[]; + warehouse_id?: string; + warehouse_name?: string; + credit_mode?: string; + source_note?: string; + movement_type?: string; +} { if (!meta) return {}; if (typeof meta === "string") { try { - return JSON.parse(meta) as { lines?: LineValues[] }; + return JSON.parse(meta) as { + lines?: LineValues[]; + warehouse_id?: string; + warehouse_name?: string; + credit_mode?: string; + source_note?: string; + }; } catch { return {}; } } - if (typeof meta === "object") return meta as { lines?: LineValues[] }; + if (typeof meta === "object") { + return meta as { + lines?: LineValues[]; + warehouse_id?: string; + warehouse_name?: string; + credit_mode?: string; + source_note?: string; + }; + } return {}; } @@ -136,6 +162,7 @@ export function OperationalDocumentPage({ partyLabel = "طرف حساب", kind = "generic", enableAccountingPost, + enableWarehouseContext = false, }: { title: string; description?: string; @@ -146,10 +173,14 @@ export function OperationalDocumentPage({ kind?: DocKind; /** When set, draft rows get "ثبت حسابداری" using the matching engine. */ enableAccountingPost?: "sales" | "purchase_gr" | "purchase_invoice" | "purchase_return"; + /** Show warehouse select separately from party (طرف حساب). */ + enableWarehouseContext?: boolean; }) { const { tenantId } = useTenantId(); const qc = useQueryClient(); const withLines = kind === "invoice" || kind === "order" || kind === "receipt" || kind === "request"; + const showPurchaseCreditMode = enableAccountingPost === "purchase_gr"; + const showMovementType = module === "inventory"; const [createOpen, setCreateOpen] = useState(false); const [detailRow, setDetailRow] = useState | null>(null); @@ -184,6 +215,11 @@ export function OperationalDocumentPage({ queryFn: () => accountingApi.setup.getPostingPolicy(tenantId!), enabled: !!tenantId, }); + const warehousesQ = useQuery({ + queryKey: ["accounting", tenantId, "warehouses"], + queryFn: () => accountingApi.ops.listWarehouses(tenantId!), + enabled: !!tenantId && enableWarehouseContext, + }); const defaults: FormValues = useMemo( () => ({ @@ -193,6 +229,11 @@ export function OperationalDocumentPage({ party_id: "", description: "", amount: "0", + warehouse_id: "", + warehouse_name: "", + credit_mode: "supplier", + source_note: "", + movement_type: "in", // Always an array so useFieldArray stays stable (even when lines UI is hidden). lines: withLines ? [{ title: "", qty: "1", unit_price: "0", note: "", item_id: "" }] : [], }), @@ -224,6 +265,19 @@ export function OperationalDocumentPage({ const createM = useMutation({ mutationFn: (d: FormValues) => { const amount = withLines ? sumLines(d.lines) : parseMoneyInput(d.amount || "0") || "0"; + const meta: Record = {}; + if (withLines) meta.lines = d.lines ?? []; + if (enableWarehouseContext) { + meta.warehouse_id = d.warehouse_id || undefined; + meta.warehouse_name = d.warehouse_name || undefined; + meta.source_note = d.source_note || undefined; + if (showPurchaseCreditMode) { + meta.credit_mode = d.credit_mode || "supplier"; + } + if (showMovementType) { + meta.movement_type = d.movement_type || "in"; + } + } return accountingApi.ops.createDocument(tenantId!, { module, doc_type: docType, @@ -234,7 +288,7 @@ export function OperationalDocumentPage({ amount, description: d.description || undefined, status: "draft", - meta_json: withLines ? JSON.stringify({ lines: d.lines ?? [] }) : undefined, + meta_json: Object.keys(meta).length ? JSON.stringify(meta) : undefined, }); }, onSuccess: async () => { @@ -249,13 +303,26 @@ export function OperationalDocumentPage({ const updateM = useMutation({ mutationFn: (d: FormValues) => { const amount = withLines ? sumLines(d.lines) : parseMoneyInput(d.amount || "0") || "0"; + const meta: Record = {}; + if (withLines) meta.lines = d.lines ?? []; + if (enableWarehouseContext) { + meta.warehouse_id = d.warehouse_id || undefined; + meta.warehouse_name = d.warehouse_name || undefined; + meta.source_note = d.source_note || undefined; + if (showPurchaseCreditMode) { + meta.credit_mode = d.credit_mode || "supplier"; + } + if (showMovementType) { + meta.movement_type = d.movement_type || "in"; + } + } return accountingApi.ops.updateDocument(tenantId!, String(editRow!.id), { doc_date: d.doc_date, party_name: d.party_name || undefined, party_id: d.party_id || undefined, amount, description: d.description || undefined, - meta_json: withLines ? JSON.stringify({ lines: d.lines ?? [] }) : undefined, + meta_json: Object.keys(meta).length ? JSON.stringify(meta) : undefined, }); }, onSuccess: async () => { @@ -356,6 +423,10 @@ export function OperationalDocumentPage({ source_document_id: String(postRow.id), amount, profile_id: profileId, + credit_mode: parseMeta(postRow.meta_json).credit_mode || "supplier", + warehouse_name: parseMeta(postRow.meta_json).warehouse_name || undefined, + source_note: parseMeta(postRow.meta_json).source_note || undefined, + party_name: postRow.party_name ? String(postRow.party_name) : undefined, }); }, onSuccess: async (data) => { @@ -394,6 +465,11 @@ export function OperationalDocumentPage({ party_id: String(row.party_id ?? ""), description: String(row.description ?? ""), amount: String(row.amount ?? "0"), + warehouse_id: meta.warehouse_id ?? "", + warehouse_name: meta.warehouse_name ?? "", + credit_mode: meta.credit_mode ?? "supplier", + source_note: meta.source_note ?? "", + movement_type: meta.movement_type ?? "in", lines: withLines ? meta.lines?.length ? meta.lines @@ -441,7 +517,7 @@ export function OperationalDocumentPage({ )} />
- + + {enableWarehouseContext ? ( + <> + {showMovementType ? ( + + + + ) : null} + + + + {showPurchaseCreditMode ? ( + <> + + + +
+ + + +
+ + ) : ( +
+ + + +
+ )} + + ) : null} {!withLines ? ( @@ -569,6 +707,38 @@ export function OperationalDocumentPage({ render: (r) => , }, { key: "party_name", header: partyLabel }, + ...(enableWarehouseContext + ? [ + ...(showMovementType + ? [ + { + key: "movement_type", + header: "نوع", + render: (r: Record) => + parseMeta(r.meta_json).movement_type === "out" ? "خروج" : "ورود", + }, + ] + : []), + { + key: "warehouse", + header: "انبار", + render: (r: Record) => + parseMeta(r.meta_json).warehouse_name || "—", + }, + ...(showPurchaseCreditMode + ? [ + { + key: "credit_mode", + header: "بستانکار", + render: (r: Record) => { + const mode = parseMeta(r.meta_json).credit_mode; + return mode === "grni" ? "GRNI" : "تأمین‌کننده"; + }, + }, + ] + : []), + ] + : []), { key: "amount", header: "مبلغ", @@ -665,6 +835,39 @@ export function OperationalDocumentPage({ {partyLabel}:{" "} {String(detailRow.party_name || "—")}

+ {enableWarehouseContext ? ( + <> + {showMovementType ? ( +

+ نوع:{" "} + {parseMeta(detailRow.meta_json).movement_type === "out" ? "خروج از انبار" : "ورود به انبار"} +

+ ) : null} +

+ انبار:{" "} + {parseMeta(detailRow.meta_json).warehouse_name || "—"} +

+ {showPurchaseCreditMode ? ( + <> +

+ طرف بستانکار:{" "} + {parseMeta(detailRow.meta_json).credit_mode === "grni" + ? "حساب GRNI (رسید در راه)" + : "تأمین‌کننده / پرداختنی"} +

+

+ مبدأ کالا:{" "} + {parseMeta(detailRow.meta_json).source_note || "—"} +

+ + ) : parseMeta(detailRow.meta_json).source_note ? ( +

+ مرجع:{" "} + {parseMeta(detailRow.meta_json).source_note} +

+ ) : null} + + ) : null}

مبلغ:{" "} {formatMoney(String(detailRow.amount ?? "0"))} diff --git a/frontend/components/accounting/SpecializedOpsPage.tsx b/frontend/components/accounting/SpecializedOpsPage.tsx index 1cd9131..e945f9f 100644 --- a/frontend/components/accounting/SpecializedOpsPage.tsx +++ b/frontend/components/accounting/SpecializedOpsPage.tsx @@ -108,10 +108,10 @@ export function SpecializedOpsPage({ doc_type: docType, number: String(v.number || "").trim() || undefined, doc_date: String(v.doc_date), - party_name: v.party_name || undefined, - party_id: v.party_id || undefined, + party_name: v.party_name ? String(v.party_name) : undefined, + party_id: v.party_id ? String(v.party_id) : undefined, amount, - description: v.description || undefined, + description: v.description ? String(v.description) : undefined, status: "draft", meta_json: JSON.stringify(meta), }); @@ -203,7 +203,14 @@ export function SpecializedOpsPage({ } + render={({ field }) => ( + + )} /> {fields.map((f) => { diff --git a/frontend/components/accounting/WorkflowScreens.tsx b/frontend/components/accounting/WorkflowScreens.tsx index 8a00f8c..fb17e73 100644 --- a/frontend/components/accounting/WorkflowScreens.tsx +++ b/frontend/components/accounting/WorkflowScreens.tsx @@ -857,7 +857,7 @@ export function ComplianceSodPage() { const schema = z.object({ code: req, name: req, - rule_type: z.string().default("sod"), + rule_type: req, condition_config: req, action_config: z.string().optional(), }); diff --git a/frontend/lib/accounting-api.ts b/frontend/lib/accounting-api.ts index 3052243..e1412c1 100644 --- a/frontend/lib/accounting-api.ts +++ b/frontend/lib/accounting-api.ts @@ -881,6 +881,7 @@ export const accountingApi = { to_type: string; to_id: string; description: string | null; + voucher_id?: string | null; }[] >("/api/v1/treasury/transfers", { tenantId }), createTransfer: ( @@ -895,11 +896,14 @@ export const accountingApi = { description?: string; } ) => - request<{ id: string }>("/api/v1/treasury/transfers", { - tenantId, - method: "POST", - body: JSON.stringify(body), - }), + request<{ id: string; voucher_id?: string | null; voucher_number?: string | null }>( + "/api/v1/treasury/transfers", + { + tenantId, + method: "POST", + body: JSON.stringify(body), + } + ), listReceipts: (tenantId: string) => request< { @@ -1009,9 +1013,11 @@ export const accountingApi = { number: string; doc_date: string; party_name: string | null; + party_id?: string | null; amount: string; status: string; description: string | null; + meta_json?: string | null; }[] >(`/api/v1/ops/documents${qs ? `?${qs}` : ""}`, { tenantId }); }, @@ -1730,7 +1736,16 @@ export const accountingApi = { ), postGoodsReceipt: ( tenantId: string, - body: { source_document_id: string; amount: string; profile_id?: string } + body: { + source_document_id: string; + amount: string; + profile_id?: string; + credit_mode?: string; + credit_account_id?: string; + warehouse_name?: string; + source_note?: string; + party_name?: string; + } ) => request<{ voucher_id: string; voucher_number?: string; status: string }>( "/api/v1/purchase-inventory/post/goods-receipt", @@ -1762,7 +1777,16 @@ export const accountingApi = { ), postPurchaseReturn: ( tenantId: string, - body: { source_document_id: string; amount: string; profile_id?: string } + body: { + source_document_id: string; + amount: string; + profile_id?: string; + source_invoice_id?: string; + source_invoice_number?: string; + party_id?: string; + party_name?: string; + lines_summary?: string; + } ) => request<{ voucher_id: string; voucher_number?: string; status: string }>( "/api/v1/purchase-inventory/post/purchase-return", @@ -1772,6 +1796,27 @@ export const accountingApi = { body: JSON.stringify(body), } ), + postInventoryIssue: ( + tenantId: string, + body: { + source_document_id: string; + amount: string; + reason: string; + debit_account_id: string; + credit_account_id?: string; + profile_id?: string; + barter_debit_account_id?: string; + barter_amount?: string; + } + ) => + request<{ voucher_id: string; voucher_number?: string; status: string }>( + "/api/v1/purchase-inventory/post/inventory-issue", + { + tenantId, + method: "POST", + body: JSON.stringify(body), + } + ), valuation: ( tenantId: string, body: { diff --git a/frontend/lib/accounting-nav.ts b/frontend/lib/accounting-nav.ts index 73cc0c6..091af78 100644 --- a/frontend/lib/accounting-nav.ts +++ b/frontend/lib/accounting-nav.ts @@ -57,7 +57,7 @@ export const ACCOUNTING_NAV: AccountingNavGroup[] = [ { href: "/accounting/treasury/receipts-payments", label: "دریافت و پرداخت" }, { href: "/accounting/treasury/cash-boxes", label: "صندوق‌ها" }, { href: "/accounting/treasury/banks", label: "بانک‌ها" }, - { href: "/accounting/treasury/transfers", label: "انتقال وجه" }, + { href: "/accounting/treasury/transfers", label: "انتقال بین حساب‌ها" }, { href: "/accounting/treasury/reconciliation", label: "تطبیق بانکی" }, { href: "/accounting/treasury/cheques", label: "چک‌ها" }, { href: "/accounting/treasury/cheques/new", label: "ثبت چک" }, @@ -170,6 +170,7 @@ export const ACCOUNTING_NAV: AccountingNavGroup[] = [ { href: "/accounting/inventory/settings", label: "تنظیمات انبار" }, { href: "/accounting/inventory/transfers", label: "انتقال انبار" }, { href: "/accounting/inventory/receipts-issues", label: "رسید ورود و خروج" }, + { href: "/accounting/inventory/issues", label: "خروج / هزینه‌کرد کالا" }, { href: "/accounting/inventory/adjustments", label: "تعدیل موجودی" }, { href: "/accounting/inventory/reports", label: "گزارش‌های انبار" }, ],