Add Phase 14 sales and income foundation
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
"""Phase 14 Sales & Income foundation.
|
||||
|
||||
Revision ID: 20260822_sales_income_foundation_p14
|
||||
Revises: 20260822_internal_accounting_model_p13
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260822_sales_income_foundation_p14"
|
||||
down_revision = "20260822_internal_accounting_model_p13"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"accounting_sales_income_transactions",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("source_type", sa.String(30), nullable=False, server_default="outward_import"),
|
||||
sa.Column("source_file_name", sa.String(255), nullable=False, server_default=""),
|
||||
sa.Column("fingerprint", sa.String(80), nullable=False),
|
||||
sa.Column("return_period", sa.String(20), nullable=False, server_default=""),
|
||||
sa.Column("document_type", sa.String(40), nullable=False, server_default="Invoice"),
|
||||
sa.Column("invoice_number", sa.String(120), nullable=False, server_default=""),
|
||||
sa.Column("invoice_date", sa.String(20), nullable=False, server_default=""),
|
||||
sa.Column("customer_name", sa.String(255), nullable=False, server_default=""),
|
||||
sa.Column("customer_gstin", sa.String(20), nullable=False, server_default=""),
|
||||
sa.Column("place_of_supply", sa.String(120), nullable=False, server_default=""),
|
||||
sa.Column("supply_type", sa.String(60), nullable=False, server_default=""),
|
||||
sa.Column("export_type", sa.String(60), nullable=False, server_default=""),
|
||||
sa.Column("hsn_code", sa.String(20), nullable=False, server_default=""),
|
||||
sa.Column("description_text", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("taxable_value", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("cgst", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("sgst", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("igst", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("cess", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("invoice_value", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("suggested_nature_id", sa.Integer(), sa.ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("suggested_ledger_name", sa.String(255), nullable=False, server_default=""),
|
||||
sa.Column("suggested_confidence", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("suggestion_reason", sa.Text(), nullable=True),
|
||||
sa.Column("review_status", sa.String(30), nullable=False, server_default="pending"),
|
||||
sa.Column("final_nature_id", sa.Integer(), sa.ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("final_ledger_name", sa.String(255), nullable=False, server_default=""),
|
||||
sa.Column("review_note", sa.Text(), nullable=True),
|
||||
sa.Column("reviewed_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("reviewed_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"client_id",
|
||||
"fingerprint",
|
||||
name="uq_accounting_sales_income_fingerprint",
|
||||
),
|
||||
)
|
||||
|
||||
for name in (
|
||||
"tenant_id", "client_id", "source_type", "fingerprint", "return_period",
|
||||
"document_type", "invoice_number", "invoice_date", "customer_name",
|
||||
"customer_gstin", "hsn_code", "suggested_nature_id", "review_status",
|
||||
"final_nature_id", "created_at_utc",
|
||||
):
|
||||
op.create_index(
|
||||
f"ix_accounting_sales_income_transactions_{name}",
|
||||
"accounting_sales_income_transactions",
|
||||
[name],
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("accounting_sales_income_transactions")
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class AccountingSalesIncomeTransaction(CommonBase):
|
||||
__tablename__ = "accounting_sales_income_transactions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"client_id",
|
||||
"fingerprint",
|
||||
name="uq_accounting_sales_income_fingerprint",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
client_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
|
||||
source_type: Mapped[str] = mapped_column(String(30), nullable=False, default="outward_import", index=True)
|
||||
source_file_name: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
fingerprint: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
|
||||
return_period: Mapped[str] = mapped_column(String(20), nullable=False, default="", index=True)
|
||||
document_type: Mapped[str] = mapped_column(String(40), nullable=False, default="Invoice", index=True)
|
||||
invoice_number: Mapped[str] = mapped_column(String(120), nullable=False, default="", index=True)
|
||||
invoice_date: Mapped[str] = mapped_column(String(20), nullable=False, default="", index=True)
|
||||
|
||||
customer_name: Mapped[str] = mapped_column(String(255), nullable=False, default="", index=True)
|
||||
customer_gstin: Mapped[str] = mapped_column(String(20), nullable=False, default="", index=True)
|
||||
place_of_supply: Mapped[str] = mapped_column(String(120), nullable=False, default="")
|
||||
supply_type: Mapped[str] = mapped_column(String(60), nullable=False, default="")
|
||||
export_type: Mapped[str] = mapped_column(String(60), nullable=False, default="")
|
||||
|
||||
hsn_code: Mapped[str] = mapped_column(String(20), nullable=False, default="", index=True)
|
||||
description_text: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
|
||||
taxable_value: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
cgst: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
sgst: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
igst: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
cess: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
invoice_value: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
|
||||
suggested_nature_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
suggested_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
suggested_confidence: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
suggestion_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
review_status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True)
|
||||
final_nature_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
final_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
review_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
reviewed_by_user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc), index=True
|
||||
)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -0,0 +1,424 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.modules.accounting.sales_models import AccountingSalesIncomeTransaction
|
||||
from app.modules.accounting.sales_taxonomy import ensure_sales_income_taxonomy, sales_income_natures
|
||||
from app.modules.accounting.taxonomy_models import AccountingNature
|
||||
|
||||
|
||||
COLUMN_ALIASES = {
|
||||
"invoice_number": [
|
||||
"invoice number", "invoice no", "invoice no.", "inv no", "document number",
|
||||
"document no", "voucher number", "bill no", "bill number",
|
||||
],
|
||||
"invoice_date": ["invoice date", "document date", "date", "voucher date", "bill date"],
|
||||
"customer_name": [
|
||||
"customer name", "recipient name", "party name", "buyer name", "customer",
|
||||
"recipient", "party",
|
||||
],
|
||||
"customer_gstin": [
|
||||
"customer gstin", "recipient gstin", "gstin", "gstin/uin", "gstin uin",
|
||||
"buyer gstin",
|
||||
],
|
||||
"document_type": ["document type", "doc type", "invoice type", "voucher type"],
|
||||
"return_period": ["return period", "tax period", "period", "month"],
|
||||
"place_of_supply": ["place of supply", "pos", "state"],
|
||||
"supply_type": ["supply type", "nature of supply", "transaction type"],
|
||||
"export_type": ["export type", "exp type"],
|
||||
"hsn_code": ["hsn", "hsn code", "hsn/sac", "sac", "sac code"],
|
||||
"description_text": [
|
||||
"description", "item description", "product description", "particulars",
|
||||
"narration", "item name", "service description",
|
||||
],
|
||||
"taxable_value": ["taxable value", "taxable amount", "assessable value", "basic amount"],
|
||||
"cgst": ["cgst", "cgst amount"],
|
||||
"sgst": ["sgst", "sgst amount", "utgst", "utgst amount"],
|
||||
"igst": ["igst", "igst amount"],
|
||||
"cess": ["cess", "cess amount"],
|
||||
"invoice_value": [
|
||||
"invoice value", "invoice amount", "total invoice value", "total amount",
|
||||
"gross amount", "bill amount", "voucher amount",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _key(value: Any) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", " ", str(value or "").strip().lower()).strip()
|
||||
|
||||
|
||||
def _s(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
try:
|
||||
if pd.isna(value):
|
||||
return ""
|
||||
except Exception:
|
||||
pass
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _f(value: Any) -> float:
|
||||
if value is None:
|
||||
return 0.0
|
||||
try:
|
||||
if pd.isna(value):
|
||||
return 0.0
|
||||
except Exception:
|
||||
pass
|
||||
text = str(value).replace(",", "").replace("₹", "").strip()
|
||||
if text.startswith("(") and text.endswith(")"):
|
||||
text = "-" + text[1:-1]
|
||||
try:
|
||||
return round(float(text or 0), 2)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _date(value: Any) -> str:
|
||||
dt = pd.to_datetime(value, errors="coerce", dayfirst=True)
|
||||
if pd.isna(dt):
|
||||
return _s(value)[:20]
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _columns(frame: pd.DataFrame) -> dict[str, str]:
|
||||
keyed = {_key(column): column for column in frame.columns}
|
||||
result = {}
|
||||
for target, aliases in COLUMN_ALIASES.items():
|
||||
for alias in aliases:
|
||||
if _key(alias) in keyed:
|
||||
result[target] = keyed[_key(alias)]
|
||||
break
|
||||
return result
|
||||
|
||||
|
||||
def _read_upload(filename: str, payload: bytes) -> pd.DataFrame:
|
||||
suffix = Path(filename or "").suffix.lower()
|
||||
if suffix == ".csv":
|
||||
return pd.read_csv(io.BytesIO(payload))
|
||||
if suffix in {".xlsx", ".xlsm", ".xls"}:
|
||||
book = pd.ExcelFile(io.BytesIO(payload))
|
||||
candidates = []
|
||||
for sheet in book.sheet_names:
|
||||
frame = pd.read_excel(book, sheet_name=sheet)
|
||||
mapping = _columns(frame)
|
||||
score = len(mapping)
|
||||
if "invoice_number" in mapping:
|
||||
score += 3
|
||||
if "customer_name" in mapping or "customer_gstin" in mapping:
|
||||
score += 2
|
||||
if "taxable_value" in mapping or "invoice_value" in mapping:
|
||||
score += 2
|
||||
candidates.append((score, sheet, frame))
|
||||
candidates.sort(key=lambda item: (-item[0], item[1]))
|
||||
if not candidates or candidates[0][0] < 3:
|
||||
raise ValueError(
|
||||
"No usable outward-sales sheet was found. Expected invoice/customer/value columns."
|
||||
)
|
||||
return candidates[0][2]
|
||||
raise ValueError("Sales & Income import supports CSV, XLSX, XLSM and XLS files.")
|
||||
|
||||
|
||||
def _nature_map(db, tenant_id: int):
|
||||
ensure_sales_income_taxonomy(db, tenant_id)
|
||||
rows = sales_income_natures(db, tenant_id)
|
||||
return {row.code: row for row in rows}
|
||||
|
||||
|
||||
def _classify(data: dict[str, Any], nature_by_code: dict[str, AccountingNature]):
|
||||
haystack = " ".join([
|
||||
_s(data.get("document_type")),
|
||||
_s(data.get("supply_type")),
|
||||
_s(data.get("export_type")),
|
||||
_s(data.get("description_text")),
|
||||
_s(data.get("customer_name")),
|
||||
]).upper()
|
||||
hsn = re.sub(r"\D", "", _s(data.get("hsn_code")))
|
||||
|
||||
def result(code, confidence, reason):
|
||||
nature = nature_by_code.get(code) or nature_by_code["SALES_REVIEW_REQUIRED"]
|
||||
return nature, confidence, reason
|
||||
|
||||
if any(token in haystack for token in ("EXPORT", "SEZ", "WITH PAYMENT", "WITHOUT PAYMENT")):
|
||||
return result("EXPORT_SALES", 92, "Export/SEZ wording in outward transaction.")
|
||||
if any(token in haystack for token in ("EXEMPT", "NIL RATED", "NIL-RATED", "NON GST", "NON-GST")):
|
||||
return result("EXEMPT_NIL_SALES", 92, "Exempt/nil/non-GST supply wording.")
|
||||
if any(token in haystack for token in ("SCRAP", "WASTE", "OLD NEWSPAPER", "METAL WASTE")):
|
||||
return result("SCRAP_SALES", 90, "Scrap/waste sale wording.")
|
||||
if any(token in haystack for token in ("COMMISSION", "BROKERAGE")):
|
||||
return result("COMMISSION_INCOME", 88, "Commission/brokerage income wording.")
|
||||
if any(token in haystack for token in ("RENTAL", "RENT ", "HIRE CHARGES", "LEASE INCOME")):
|
||||
return result("RENTAL_INCOME", 86, "Rental/hire/lease income wording.")
|
||||
if any(token in haystack for token in ("INTEREST", "FD INTEREST", "LOAN INTEREST")):
|
||||
return result("INTEREST_INCOME", 90, "Interest income wording.")
|
||||
if any(token in haystack for token in ("JOB WORK", "JOBWORK", "PROCESSING CHARGES", "CONVERSION CHARGES")):
|
||||
return result("JOB_WORK_INCOME", 88, "Job-work/processing income wording.")
|
||||
if any(token in haystack for token in (
|
||||
"PROFESSIONAL", "CONSULTANCY", "SERVICE CHARGES", "SERVICE FEE",
|
||||
"AUDIT FEE", "ACCOUNTING FEE", "MANAGEMENT FEE", "LABOUR CHARGES",
|
||||
)):
|
||||
return result("SERVICE_INCOME", 86, "Service/professional income wording.")
|
||||
if any(token in haystack for token in (
|
||||
"SALE OF VEHICLE", "SALE OF MACHINERY", "SALE OF ASSET",
|
||||
"OLD VEHICLE SALE", "OLD MACHINERY SALE",
|
||||
)):
|
||||
return result("ASSET_SALE", 90, "Capital-asset disposal wording.")
|
||||
|
||||
# SAC/service HSN chapters commonly begin 99. This is only a first-pass signal.
|
||||
if hsn.startswith("99"):
|
||||
return result("SERVICE_INCOME", 78, "SAC/HSN 99xx indicates service supply.")
|
||||
|
||||
# Generic outward invoice with goods HSN is treated conservatively as trading sales.
|
||||
if hsn and not hsn.startswith("99"):
|
||||
return result(
|
||||
"TRADING_SALES",
|
||||
68,
|
||||
"Goods HSN present; trading/manufacturing distinction requires business context or review.",
|
||||
)
|
||||
|
||||
if _s(data.get("description_text")):
|
||||
return result(
|
||||
"OTHER_OPERATING_INCOME",
|
||||
55,
|
||||
"Outward transaction has description but insufficient evidence for a narrower nature.",
|
||||
)
|
||||
return result("SALES_REVIEW_REQUIRED", 30, "Insufficient evidence for reliable income classification.")
|
||||
|
||||
|
||||
def _fingerprint(client_id: int, data: dict[str, Any]) -> str:
|
||||
raw = "|".join([
|
||||
str(client_id),
|
||||
_s(data.get("invoice_number")).upper(),
|
||||
_s(data.get("invoice_date")),
|
||||
_s(data.get("customer_gstin")).upper(),
|
||||
_s(data.get("customer_name")).upper(),
|
||||
f"{_f(data.get('taxable_value')):.2f}",
|
||||
f"{_f(data.get('invoice_value')):.2f}",
|
||||
_s(data.get("document_type")).upper(),
|
||||
])
|
||||
return hashlib.sha256(raw.encode("utf-8", "ignore")).hexdigest()
|
||||
|
||||
|
||||
def import_sales_file(
|
||||
db,
|
||||
*,
|
||||
tenant_id: int,
|
||||
client_id: int,
|
||||
filename: str,
|
||||
payload: bytes,
|
||||
user_id: int,
|
||||
source_type: str = "outward_import",
|
||||
):
|
||||
frame = _read_upload(filename, payload)
|
||||
mapping = _columns(frame)
|
||||
|
||||
required = {"invoice_number"}
|
||||
if not required.issubset(mapping):
|
||||
raise ValueError("Could not identify an Invoice Number column in the uploaded file.")
|
||||
if not ({"customer_name", "customer_gstin"} & set(mapping)):
|
||||
raise ValueError("Could not identify Customer Name or Customer GSTIN.")
|
||||
if not ({"taxable_value", "invoice_value"} & set(mapping)):
|
||||
raise ValueError("Could not identify Taxable Value or Invoice Value.")
|
||||
|
||||
nature_by_code = _nature_map(db, tenant_id)
|
||||
inserted = 0
|
||||
duplicates = 0
|
||||
skipped_blank = 0
|
||||
|
||||
for _, row in frame.iterrows():
|
||||
data = {
|
||||
target: row[column]
|
||||
for target, column in mapping.items()
|
||||
}
|
||||
data = {
|
||||
"invoice_number": _s(data.get("invoice_number")),
|
||||
"invoice_date": _date(data.get("invoice_date")),
|
||||
"customer_name": _s(data.get("customer_name")),
|
||||
"customer_gstin": re.sub(r"\s+", "", _s(data.get("customer_gstin")).upper()),
|
||||
"document_type": _s(data.get("document_type")) or "Invoice",
|
||||
"return_period": _s(data.get("return_period")),
|
||||
"place_of_supply": _s(data.get("place_of_supply")),
|
||||
"supply_type": _s(data.get("supply_type")),
|
||||
"export_type": _s(data.get("export_type")),
|
||||
"hsn_code": re.sub(r"\.0$", "", _s(data.get("hsn_code"))),
|
||||
"description_text": _s(data.get("description_text")),
|
||||
"taxable_value": _f(data.get("taxable_value")),
|
||||
"cgst": _f(data.get("cgst")),
|
||||
"sgst": _f(data.get("sgst")),
|
||||
"igst": _f(data.get("igst")),
|
||||
"cess": _f(data.get("cess")),
|
||||
"invoice_value": _f(data.get("invoice_value")),
|
||||
}
|
||||
|
||||
if not data["invoice_number"] and not data["customer_name"] and not data["customer_gstin"]:
|
||||
skipped_blank += 1
|
||||
continue
|
||||
if data["invoice_value"] == 0:
|
||||
data["invoice_value"] = round(
|
||||
data["taxable_value"] + data["cgst"] + data["sgst"] + data["igst"] + data["cess"],
|
||||
2,
|
||||
)
|
||||
|
||||
fp = _fingerprint(client_id, data)
|
||||
exists = db.execute(
|
||||
select(AccountingSalesIncomeTransaction.id).where(
|
||||
AccountingSalesIncomeTransaction.tenant_id == int(tenant_id),
|
||||
AccountingSalesIncomeTransaction.client_id == int(client_id),
|
||||
AccountingSalesIncomeTransaction.fingerprint == fp,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists:
|
||||
duplicates += 1
|
||||
continue
|
||||
|
||||
nature, confidence, reason = _classify(data, nature_by_code)
|
||||
record = AccountingSalesIncomeTransaction(
|
||||
tenant_id=int(tenant_id),
|
||||
client_id=int(client_id),
|
||||
source_type=source_type,
|
||||
source_file_name=Path(filename or "").name[:255],
|
||||
fingerprint=fp,
|
||||
return_period=data["return_period"],
|
||||
document_type=data["document_type"],
|
||||
invoice_number=data["invoice_number"],
|
||||
invoice_date=data["invoice_date"],
|
||||
customer_name=data["customer_name"],
|
||||
customer_gstin=data["customer_gstin"],
|
||||
place_of_supply=data["place_of_supply"],
|
||||
supply_type=data["supply_type"],
|
||||
export_type=data["export_type"],
|
||||
hsn_code=data["hsn_code"],
|
||||
description_text=data["description_text"],
|
||||
taxable_value=data["taxable_value"],
|
||||
cgst=data["cgst"],
|
||||
sgst=data["sgst"],
|
||||
igst=data["igst"],
|
||||
cess=data["cess"],
|
||||
invoice_value=data["invoice_value"],
|
||||
suggested_nature_id=nature.id,
|
||||
suggested_confidence=int(confidence),
|
||||
suggestion_reason=reason,
|
||||
review_status="suggested" if confidence >= 60 else "review_required",
|
||||
created_by_user_id=user_id,
|
||||
)
|
||||
db.add(record)
|
||||
inserted += 1
|
||||
|
||||
db.commit()
|
||||
return {
|
||||
"inserted": inserted,
|
||||
"duplicates": duplicates,
|
||||
"skipped_blank": skipped_blank,
|
||||
"source_rows": len(frame),
|
||||
}
|
||||
|
||||
|
||||
def list_sales(
|
||||
db,
|
||||
*,
|
||||
tenant_id: int,
|
||||
client_id: int,
|
||||
status: str = "",
|
||||
customer: str = "",
|
||||
period: str = "",
|
||||
page: int = 1,
|
||||
per_page: int = 25,
|
||||
):
|
||||
where = [
|
||||
AccountingSalesIncomeTransaction.tenant_id == int(tenant_id),
|
||||
AccountingSalesIncomeTransaction.client_id == int(client_id),
|
||||
]
|
||||
if status and status != "all":
|
||||
where.append(AccountingSalesIncomeTransaction.review_status == status)
|
||||
if customer.strip():
|
||||
term = f"%{customer.strip()}%"
|
||||
where.append(
|
||||
(
|
||||
AccountingSalesIncomeTransaction.customer_name.ilike(term)
|
||||
| AccountingSalesIncomeTransaction.customer_gstin.ilike(term)
|
||||
)
|
||||
)
|
||||
if period.strip():
|
||||
where.append(AccountingSalesIncomeTransaction.return_period == period.strip())
|
||||
|
||||
total = int(
|
||||
db.scalar(
|
||||
select(func.count(AccountingSalesIncomeTransaction.id)).where(*where)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
per_page = max(10, min(100, int(per_page or 25)))
|
||||
pages = max(1, (total + per_page - 1) // per_page)
|
||||
page = max(1, min(int(page or 1), pages))
|
||||
|
||||
rows = list(
|
||||
db.execute(
|
||||
select(AccountingSalesIncomeTransaction)
|
||||
.where(*where)
|
||||
.order_by(
|
||||
AccountingSalesIncomeTransaction.invoice_date.desc(),
|
||||
AccountingSalesIncomeTransaction.id.desc(),
|
||||
)
|
||||
.offset((page - 1) * per_page)
|
||||
.limit(per_page)
|
||||
).scalars().all()
|
||||
)
|
||||
return rows, total, page, pages
|
||||
|
||||
|
||||
def review_sales(
|
||||
db,
|
||||
*,
|
||||
tenant_id: int,
|
||||
client_id: int,
|
||||
row_id: int,
|
||||
final_nature_id: int,
|
||||
final_ledger_name: str,
|
||||
review_note: str,
|
||||
user_id: int,
|
||||
):
|
||||
row = db.execute(
|
||||
select(AccountingSalesIncomeTransaction).where(
|
||||
AccountingSalesIncomeTransaction.id == int(row_id),
|
||||
AccountingSalesIncomeTransaction.tenant_id == int(tenant_id),
|
||||
AccountingSalesIncomeTransaction.client_id == int(client_id),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not row:
|
||||
raise ValueError("Sales / income transaction was not found.")
|
||||
|
||||
nature = db.execute(
|
||||
select(AccountingNature).where(
|
||||
AccountingNature.id == int(final_nature_id),
|
||||
AccountingNature.tenant_id == int(tenant_id),
|
||||
AccountingNature.classification_group == "income",
|
||||
AccountingNature.is_posting_nature.is_(True),
|
||||
AccountingNature.is_active.is_(True),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not nature:
|
||||
raise ValueError("Select an active Sales & Income accounting nature.")
|
||||
|
||||
row.final_nature_id = nature.id
|
||||
row.final_ledger_name = (final_ledger_name or "").strip()
|
||||
row.review_note = (review_note or "").strip() or None
|
||||
row.review_status = "reviewed"
|
||||
row.reviewed_by_user_id = user_id
|
||||
row.reviewed_at_utc = _utcnow()
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.modules.accounting.taxonomy_models import AccountingNature
|
||||
|
||||
|
||||
SALES_INCOME_NATURES = [
|
||||
# code, name, parent, group, cap/revenue, posting, sort, description
|
||||
("SALES_INCOME", "Sales & Income", None, "income", "revenue", False, 1100, "Sales and income classification root."),
|
||||
("TRADING_SALES", "Trading / Stock Sales", "SALES_INCOME", "income", "revenue", True, 1110, "Sale of goods principally held for resale."),
|
||||
("MANUFACTURED_GOODS_SALES", "Manufactured Goods Sales", "SALES_INCOME", "income", "revenue", True, 1120, "Sale of goods manufactured or produced by the entity."),
|
||||
("SERVICE_INCOME", "Service / Professional Income", "SALES_INCOME", "income", "revenue", True, 1130, "Revenue from services, professional work or contractual services."),
|
||||
("EXPORT_SALES", "Export Sales / Services", "SALES_INCOME", "income", "revenue", True, 1140, "Export of goods or services."),
|
||||
("EXEMPT_NIL_SALES", "Exempt / Nil-Rated Sales", "SALES_INCOME", "income", "revenue", True, 1150, "Sales or supplies treated as exempt or nil-rated."),
|
||||
("SCRAP_SALES", "Scrap / Waste Sales", "SALES_INCOME", "income", "revenue", True, 1160, "Sale of scrap, waste or residual materials."),
|
||||
("JOB_WORK_INCOME", "Job Work / Processing Income", "SALES_INCOME", "income", "revenue", True, 1170, "Income from job work, processing or conversion activity."),
|
||||
("COMMISSION_INCOME", "Commission / Brokerage Income", "SALES_INCOME", "income", "revenue", True, 1180, "Commission, brokerage or intermediary income."),
|
||||
("RENTAL_INCOME", "Rental / Hire Income", "SALES_INCOME", "income", "revenue", True, 1190, "Business rental, lease or hire income."),
|
||||
("INTEREST_INCOME", "Interest Income", "SALES_INCOME", "income", "revenue", True, 1200, "Interest earned and accounted as income."),
|
||||
("OTHER_OPERATING_INCOME", "Other Operating Income", "SALES_INCOME", "income", "revenue", True, 1210, "Other income arising from normal operations."),
|
||||
("OTHER_INCOME", "Other Income", "SALES_INCOME", "income", "revenue", True, 1220, "Non-operating income not represented by a more specific nature."),
|
||||
("ASSET_SALE", "Sale of Capital Asset", "SALES_INCOME", "income", "capital", True, 1230, "Disposal proceeds relating to capital assets; reviewer must determine final accounting treatment."),
|
||||
("SALES_REVIEW_REQUIRED", "Sales / Income Review Required", "SALES_INCOME", "income", "review", True, 1290, "Fallback for outward transactions with insufficient evidence."),
|
||||
]
|
||||
|
||||
|
||||
def ensure_sales_income_taxonomy(db, tenant_id: int, actor_user_id: int | None = None) -> int:
|
||||
existing = set(
|
||||
db.execute(
|
||||
select(AccountingNature.code).where(AccountingNature.tenant_id == int(tenant_id))
|
||||
).scalars().all()
|
||||
)
|
||||
inserted = 0
|
||||
for code, name, parent_code, group, cap_rev, posting, sort_order, description in SALES_INCOME_NATURES:
|
||||
if code in existing:
|
||||
continue
|
||||
db.add(
|
||||
AccountingNature(
|
||||
tenant_id=int(tenant_id),
|
||||
code=code,
|
||||
name=name,
|
||||
parent_code=parent_code,
|
||||
classification_group=group,
|
||||
capital_revenue=cap_rev,
|
||||
description=description,
|
||||
sort_order=sort_order,
|
||||
is_posting_nature=bool(posting),
|
||||
is_active=True,
|
||||
is_system_defined=True,
|
||||
created_by_user_id=actor_user_id,
|
||||
updated_by_user_id=actor_user_id,
|
||||
)
|
||||
)
|
||||
inserted += 1
|
||||
if inserted:
|
||||
db.commit()
|
||||
return inserted
|
||||
|
||||
|
||||
def sales_income_natures(db, tenant_id: int):
|
||||
ensure_sales_income_taxonomy(db, tenant_id)
|
||||
return list(
|
||||
db.execute(
|
||||
select(AccountingNature).where(
|
||||
AccountingNature.tenant_id == int(tenant_id),
|
||||
AccountingNature.classification_group == "income",
|
||||
AccountingNature.is_posting_nature.is_(True),
|
||||
AccountingNature.is_active.is_(True),
|
||||
).order_by(AccountingNature.sort_order, AccountingNature.name)
|
||||
).scalars().all()
|
||||
)
|
||||
@@ -0,0 +1,209 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import APIRouter, File, Form, Request, UploadFile
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from app.core.db.common import CommonSessionLocal
|
||||
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
|
||||
from app.core.templating import templates
|
||||
from app.modules.accounting.sales_service import import_sales_file, list_sales, review_sales
|
||||
from app.modules.accounting.sales_taxonomy import sales_income_natures
|
||||
from app.modules.accounting.ui import _find_visible_client, _require_partner, _visible_clients
|
||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/tools/accounting/sales-income",
|
||||
tags=["accounting-sales-income-ui"],
|
||||
)
|
||||
|
||||
|
||||
def _go(client_id=0, message="", error="", **filters):
|
||||
query = {}
|
||||
if client_id:
|
||||
query["client_id"] = client_id
|
||||
if message:
|
||||
query["message"] = message[:300]
|
||||
if error:
|
||||
query["error"] = error[:300]
|
||||
for key, value in filters.items():
|
||||
if value not in (None, "", "all"):
|
||||
query[key] = value
|
||||
return RedirectResponse(
|
||||
"/tools/accounting/sales-income" + ("?" + urlencode(query) if query else ""),
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def page(
|
||||
request: Request,
|
||||
client_id: int | None = None,
|
||||
status: str = "all",
|
||||
customer: str = "",
|
||||
period: str = "",
|
||||
page: int = 1,
|
||||
per_page: int = 25,
|
||||
message: str = "",
|
||||
error: str = "",
|
||||
):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, denied = _require_partner(request, db, "accounting.learning.view")
|
||||
if denied:
|
||||
return denied
|
||||
|
||||
clients, scope = _visible_clients(db, request, user)
|
||||
selected = next(
|
||||
(client for client in clients if client_id and int(client.id) == int(client_id)),
|
||||
None,
|
||||
)
|
||||
|
||||
rows = []
|
||||
total = 0
|
||||
pages = 1
|
||||
if selected:
|
||||
rows, total, page, pages = list_sales(
|
||||
db,
|
||||
tenant_id=scope.tenant_id,
|
||||
client_id=selected.id,
|
||||
status=status,
|
||||
customer=customer,
|
||||
period=period,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"modules/accounting/templates/accounting/sales_income.html",
|
||||
{
|
||||
"request": request,
|
||||
"current_user": user,
|
||||
"current_user_roles": get_user_roles(db, user.id),
|
||||
"current_user_permissions": get_user_permissions(db, user.id),
|
||||
"csrf_token": get_or_create_csrf_token(request),
|
||||
"title": "Sales & Income",
|
||||
"clients": clients,
|
||||
"selected_client": selected,
|
||||
"rows": rows,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pages": pages,
|
||||
"per_page": per_page,
|
||||
"status_filter": status,
|
||||
"customer_filter": customer,
|
||||
"period_filter": period,
|
||||
"natures": sales_income_natures(db, scope.tenant_id),
|
||||
"message": message,
|
||||
"error": error,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
async def import_file(
|
||||
request: Request,
|
||||
client_id: int = Form(...),
|
||||
source_type: str = Form("outward_import"),
|
||||
csrf_token: str = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, denied = _require_partner(request, db, "accounting.learning.manage")
|
||||
if denied:
|
||||
return denied
|
||||
client, _, scope = _find_visible_client(db, request, user, client_id)
|
||||
if not client:
|
||||
return _go(error="Client is not visible.")
|
||||
|
||||
payload = await file.read()
|
||||
if not payload:
|
||||
raise ValueError("Uploaded Sales & Income file is empty.")
|
||||
if len(payload) > 25 * 1024 * 1024:
|
||||
raise ValueError("Sales & Income import file exceeds the 25 MB limit.")
|
||||
|
||||
result = import_sales_file(
|
||||
db,
|
||||
tenant_id=scope.tenant_id,
|
||||
client_id=client.id,
|
||||
filename=file.filename or "sales.xlsx",
|
||||
payload=payload,
|
||||
user_id=user.id,
|
||||
source_type=(source_type or "outward_import").strip()[:30],
|
||||
)
|
||||
return _go(
|
||||
client.id,
|
||||
message=(
|
||||
f"Imported {result['inserted']} sales/income row(s); "
|
||||
f"{result['duplicates']} duplicate(s) skipped."
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
return _go(client_id, error=str(exc))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/{row_id}/review")
|
||||
def review(
|
||||
request: Request,
|
||||
row_id: int,
|
||||
client_id: int = Form(...),
|
||||
final_nature_id: int = Form(...),
|
||||
final_ledger_name: str = Form(""),
|
||||
review_note: str = Form(""),
|
||||
status: str = Form("all"),
|
||||
customer: str = Form(""),
|
||||
period: str = Form(""),
|
||||
page: int = Form(1),
|
||||
per_page: int = Form(25),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, denied = _require_partner(request, db, "accounting.learning.manage")
|
||||
if denied:
|
||||
return denied
|
||||
client, _, scope = _find_visible_client(db, request, user, client_id)
|
||||
if not client:
|
||||
return _go(error="Client is not visible.")
|
||||
|
||||
review_sales(
|
||||
db,
|
||||
tenant_id=scope.tenant_id,
|
||||
client_id=client.id,
|
||||
row_id=row_id,
|
||||
final_nature_id=final_nature_id,
|
||||
final_ledger_name=final_ledger_name,
|
||||
review_note=review_note,
|
||||
user_id=user.id,
|
||||
)
|
||||
return _go(
|
||||
client.id,
|
||||
message="Sales / income accounting treatment reviewed.",
|
||||
status=status,
|
||||
customer=customer,
|
||||
period=period,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
)
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
return _go(
|
||||
client_id,
|
||||
error=str(exc),
|
||||
status=status,
|
||||
customer=customer,
|
||||
period=period,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,156 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Accounting · Sales & Income · Phase 14</p>
|
||||
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Sales & Income Foundation</h1>
|
||||
<p class="mt-1 max-w-4xl text-sm text-slate-500">Normalized outward transaction register for GSTR-1/exported sales data, customer/GSTIN identity, income-nature classification and reviewer-confirmed sales ledger mapping. Phase 14 is review-only; Tally sales posting is not enabled yet.</p>
|
||||
</div>
|
||||
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold">Back to Accounting</a>
|
||||
</div>
|
||||
|
||||
{% if message %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">{{ message }}</div>{% endif %}
|
||||
{% if error %}<div class="rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-800">{{ error }}</div>{% endif %}
|
||||
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<form method="get" class="grid gap-4 md:grid-cols-[1fr_auto]">
|
||||
<label class="text-sm font-semibold">ERP Client
|
||||
<select name="client_id" required class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2">
|
||||
<option value="">Select client</option>
|
||||
{% for c in clients %}
|
||||
<option value="{{ c.id }}" {% if selected_client and c.id == selected_client.id %}selected{% endif %}>{{ c.client_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<div class="flex items-end"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Open Sales & Income</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if selected_client %}
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<h2 class="font-semibold text-slate-900">Import Outward Sales / Income</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Upload CSV/XLS/XLSX exported from GSTR-1 working papers, billing software or another outward-sales source. Flexible common column names are detected automatically. Duplicate invoice fingerprints are skipped.</p>
|
||||
<form method="post" action="/tools/accounting/sales-income/import" enctype="multipart/form-data" class="mt-4 grid gap-4 md:grid-cols-[220px_1fr_auto] md:items-end">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
||||
<label class="text-sm font-semibold">Source
|
||||
<select name="source_type" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2">
|
||||
<option value="outward_import">Outward Sales Import</option>
|
||||
<option value="gstr1">GSTR-1</option>
|
||||
<option value="einvoice_outward">E-Invoice Outward</option>
|
||||
<option value="ewaybill_outward">E-Way Bill Outward</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-sm font-semibold">CSV / Excel file
|
||||
<input type="file" name="file" accept=".csv,.xlsx,.xls,.xlsm" required class="mt-1 block w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
|
||||
</label>
|
||||
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Import</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<form method="get" class="grid gap-3 md:grid-cols-5">
|
||||
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
||||
<label class="text-xs font-semibold">Status
|
||||
<select name="status" class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2">
|
||||
{% for value,label in [('all','All'),('suggested','Suggested'),('review_required','Review Required'),('reviewed','Reviewed')] %}
|
||||
<option value="{{ value }}" {% if status_filter==value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-xs font-semibold md:col-span-2">Customer / GSTIN
|
||||
<input name="customer" value="{{ customer_filter }}" class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2">
|
||||
</label>
|
||||
<label class="text-xs font-semibold">Return Period
|
||||
<input name="period" value="{{ period_filter }}" placeholder="2026-07" class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2">
|
||||
</label>
|
||||
<div class="flex items-end"><button class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold">Filter</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-5 py-4">
|
||||
<h2 class="font-semibold">{{ selected_client.client_name }} · Sales & Income Register</h2>
|
||||
<p class="text-sm text-slate-500">{{ total }} normalized outward transaction(s).</p>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
<article class="p-5">
|
||||
<div class="grid gap-4 xl:grid-cols-12">
|
||||
<div class="xl:col-span-3">
|
||||
<div class="font-semibold">{{ row.invoice_date or 'No date' }} · {{ row.invoice_number or 'No invoice no.' }}</div>
|
||||
<div class="mt-1 text-sm">{{ row.customer_name or 'Unnamed customer' }}</div>
|
||||
<div class="text-xs text-slate-500">{{ row.customer_gstin or 'No GSTIN' }}</div>
|
||||
<div class="mt-2 text-sm">{{ row.description_text or '—' }}</div>
|
||||
<div class="mt-2 font-semibold">₹{{ '%.2f'|format(row.invoice_value) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="xl:col-span-3">
|
||||
<div class="text-xs uppercase text-slate-500">First-pass suggestion</div>
|
||||
<div class="mt-1 font-semibold">
|
||||
{% for n in natures %}{% if n.id == row.suggested_nature_id %}{{ n.name }}{% endif %}{% endfor %}
|
||||
</div>
|
||||
<div class="text-sm">{{ row.suggested_confidence }}% confidence</div>
|
||||
<div class="mt-2 text-xs text-slate-500">{{ row.suggestion_reason or '—' }}</div>
|
||||
<div class="mt-2 text-xs text-slate-500">{{ row.source_type }} · {{ row.hsn_code or 'No HSN/SAC' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="xl:col-span-6">
|
||||
{% if row.review_status != 'reviewed' %}
|
||||
<form method="post" action="/tools/accounting/sales-income/{{ row.id }}/review" class="grid gap-2 md:grid-cols-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
||||
<input type="hidden" name="status" value="{{ status_filter }}">
|
||||
<input type="hidden" name="customer" value="{{ customer_filter }}">
|
||||
<input type="hidden" name="period" value="{{ period_filter }}">
|
||||
<input type="hidden" name="page" value="{{ page }}">
|
||||
<input type="hidden" name="per_page" value="{{ per_page }}">
|
||||
|
||||
<label class="text-xs font-semibold">Sales / Income Nature
|
||||
<select name="final_nature_id" required class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2">
|
||||
{% for n in natures %}
|
||||
<option value="{{ n.id }}" {% if n.id == row.suggested_nature_id %}selected{% endif %}>{{ n.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-xs font-semibold">Client Tally Sales / Income Ledger
|
||||
<input name="final_ledger_name" value="{{ row.suggested_ledger_name }}" placeholder="Reviewer maps existing Tally ledger" class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2">
|
||||
</label>
|
||||
<label class="text-xs font-semibold md:col-span-2">Review note
|
||||
<input name="review_note" class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2">
|
||||
</label>
|
||||
<button class="rounded-lg bg-brand-600 px-3 py-2 text-sm font-semibold text-white md:col-span-2">Confirm Sales / Income Treatment</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
||||
<strong>Reviewed.</strong>
|
||||
{% for n in natures %}{% if n.id == row.final_nature_id %}{{ n.name }}{% endif %}{% endfor %}
|
||||
{% if row.final_ledger_name %} · {{ row.final_ledger_name }}{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% else %}
|
||||
<div class="p-10 text-center text-sm text-slate-500">No Sales & Income records yet. Import an outward-sales CSV/Excel file above.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if pages > 1 %}
|
||||
<div class="flex items-center justify-between border-t border-slate-200 p-4 text-sm">
|
||||
<span>Page {{ page }} of {{ pages }}</span>
|
||||
<div class="flex gap-2">
|
||||
{% if page > 1 %}
|
||||
<a class="rounded-lg border border-slate-300 px-3 py-1" href="?client_id={{ selected_client.id }}&status={{ status_filter }}&customer={{ customer_filter }}&period={{ period_filter }}&page={{ page-1 }}&per_page={{ per_page }}">Previous</a>
|
||||
{% endif %}
|
||||
{% if page < pages %}
|
||||
<a class="rounded-lg border border-slate-300 px-3 py-1" href="?client_id={{ selected_client.id }}&status={{ status_filter }}&customer={{ customer_filter }}&period={{ period_filter }}&page={{ page+1 }}&per_page={{ per_page }}">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -10,21 +10,44 @@
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Tools · Accounting</p>
|
||||
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Tally Accounting</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Company mapping, master sync and transaction sync remain available. Phase 5 adds review-only accounting tools without Tally write-back.</p>
|
||||
<p class="mt-1 text-sm text-slate-500">Company mapping, master sync and transaction sync remain available. Accounting tools are now grouped into Purchases & Expenses and Sales & Income without changing existing routes or controls.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/tools/accounting/taxonomy" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Accounting Taxonomy</a>
|
||||
{% if selected_client %}<a href="/tools/accounting/historical-learning?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Historical Learning</a>{% endif %}
|
||||
{% if selected_client %}<a href="/tools/accounting/ledger-learning?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Ledger Learning</a>{% endif %}
|
||||
{% if selected_client %}<a href="/tools/accounting/gstr2b?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">GSTR-2B Intelligence</a>{% endif %}
|
||||
{% if selected_client %}<a href="/tools/accounting/purchase-enrichment?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">E-Invoice / E-Way Bill</a>{% endif %}
|
||||
{% if selected_client %}<a href="/tools/accounting/purchase-review?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Purchase Review</a>{% endif %}
|
||||
{% if selected_client %}<a href="/tools/accounting/purchase-posting?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Post to Tally</a>{% endif %}
|
||||
{% if selected_client %}<a href="/tools/accounting/bank-posting?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Bank → Tally</a>{% endif %}
|
||||
<a href="/tools/accounting/ai{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-indigo-300 bg-indigo-50 px-4 py-2 text-sm font-semibold text-indigo-800 hover:bg-indigo-100">Accounting AI</a>
|
||||
<a href="/tools/accounting/internal-model" class="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-800 hover:bg-emerald-100">Internal Model</a>
|
||||
{% if selected_client %}<a href="/tools/tally/depreciation?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Depreciation (IT)</a>{% endif %}
|
||||
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}&refresh=1{% else %}?refresh=1{% endif %}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Refresh Tally Companies</a>
|
||||
<div class="w-full space-y-3 xl:max-w-5xl">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-[0.14em] text-amber-700">Purchases & Expenses</div>
|
||||
<div class="text-xs text-slate-500">Existing Phase 3–13 tools are grouped here; their URLs and behavior are unchanged.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/tools/accounting/taxonomy" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Accounting Taxonomy</a>
|
||||
{% if selected_client %}<a href="/tools/accounting/historical-learning?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">Historical Learning</a>{% endif %}
|
||||
{% if selected_client %}<a href="/tools/accounting/ledger-learning?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">Ledger Learning</a>{% endif %}
|
||||
{% if selected_client %}<a href="/tools/accounting/gstr2b?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">GSTR-2B Intelligence</a>{% endif %}
|
||||
{% if selected_client %}<a href="/tools/accounting/purchase-enrichment?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">E-Invoice / E-Way Bill</a>{% endif %}
|
||||
{% if selected_client %}<a href="/tools/accounting/purchase-review?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">Purchase Review</a>{% endif %}
|
||||
{% if selected_client %}<a href="/tools/accounting/purchase-posting?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">Purchase → Tally</a>{% endif %}
|
||||
{% if selected_client %}<a href="/tools/accounting/bank-posting?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">Bank Expenses / Payments</a>{% endif %}
|
||||
{% if selected_client %}<a href="/tools/tally/depreciation?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">Depreciation (IT)</a>{% endif %}
|
||||
<a href="/tools/accounting/ai{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-indigo-300 bg-indigo-50 px-3 py-2 text-sm font-semibold text-indigo-800">Accounting AI</a>
|
||||
<a href="/tools/accounting/internal-model" class="rounded-xl border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-800">Internal Model</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50/40 p-4">
|
||||
<div class="mb-3">
|
||||
<div class="text-xs font-semibold uppercase tracking-[0.14em] text-emerald-700">Sales & Income</div>
|
||||
<div class="text-xs text-slate-500">Phase 14 starts the outward-sales/customer/income workflow. Existing accounting infrastructure is reused.</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% if selected_client %}<a href="/tools/accounting/sales-income?client_id={{ selected_client.id }}" class="rounded-xl bg-emerald-700 px-3 py-2 text-sm font-semibold text-white">Sales & Income Foundation</a>{% else %}<a href="/tools/accounting/sales-income" class="rounded-xl bg-emerald-700 px-3 py-2 text-sm font-semibold text-white">Sales & Income Foundation</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}&refresh=1{% else %}?refresh=1{% endif %}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Refresh Tally Companies</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ from app.modules.accounting.purchase_posting_ui import router as accounting_purc
|
||||
from app.modules.accounting.bank_ui import router as accounting_bank_ui_router
|
||||
from app.modules.accounting.ai_ui import router as accounting_ai_ui_router
|
||||
from app.modules.accounting.internal_model_ui import router as accounting_internal_model_ui_router
|
||||
from app.modules.accounting.sales_ui import router as accounting_sales_income_ui_router
|
||||
from app.modules.registrations.ui import router as registrations_ui_router
|
||||
from app.modules.credential_vault.ui import router as credential_vault_ui_router
|
||||
from app.modules.client_identity.ui import router as client_identity_ui_router
|
||||
@@ -80,6 +81,7 @@ def mount_ui(app: FastAPI) -> None:
|
||||
app.include_router(accounting_bank_ui_router)
|
||||
app.include_router(accounting_ai_ui_router)
|
||||
app.include_router(accounting_internal_model_ui_router)
|
||||
app.include_router(accounting_sales_income_ui_router)
|
||||
app.include_router(work_tracker_ui_router)
|
||||
app.include_router(billing_ui_router)
|
||||
app.include_router(platform_billing_ui_router)
|
||||
|
||||
Reference in New Issue
Block a user