Muluhabt ERP modules

This commit is contained in:
Mulu Mehari
2026-08-25 00:11:39 +03:00
parent 5c2100e76d
commit 70171fa9d8
441 changed files with 68587 additions and 214 deletions

View File

@@ -0,0 +1,329 @@
import { Audit, SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import {
Check,
Column,
Entity,
Index,
PrimaryGeneratedColumn,
} from "typeorm";
import { moneyColumn } from "../../../common/money";
/**
* Only straight line is implemented.
*
* The column is an enum rather than a boolean so reducing-balance can be added
* without a migration, but nothing else is supported today and the calculator
* rejects anything else rather than silently treating it as straight line.
*/
export const DEPRECIATION_METHODS = ["STRAIGHT_LINE"] as const;
export type DepreciationMethod = (typeof DEPRECIATION_METHODS)[number];
export const ASSET_STATUSES = [
"ACTIVE",
"FULLY_DEPRECIATED",
"DISPOSED",
"WRITTEN_OFF",
] as const;
export type AssetStatus = (typeof ASSET_STATUSES)[number];
export const DISPOSAL_TYPES = ["SALE", "SCRAP", "WRITE_OFF"] as const;
export type DisposalType = (typeof DISPOSAL_TYPES)[number];
/**
* Depreciation defaults for a class of asset, and the three accounts its
* postings touch: where the cost sits, where the accumulated depreciation
* accrues, and where the charge lands.
*
* Holding the accounts here rather than resolving them by code per asset means
* a chart can name its asset accounts whatever it likes; only the category has
* to be set up once.
*/
@Entity({ schema: "finance", name: "asset_categories" })
@Check("ck_asset_categories_life", `"default_life_months" > 0`)
@Check(
"ck_asset_categories_salvage",
`"default_salvage_rate" >= 0 AND "default_salvage_rate" < 1`,
)
export class AssetCategory extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "varchar", length: 32, name: "code" })
code!: string;
@Column({ type: "jsonb", name: "name" })
name!: { am: string; en: string };
/** Where the asset's cost is carried (1211 Land, 1213 Locomotives…). */
@Column({ type: "uuid", name: "asset_account_id" })
assetAccountId!: string;
/** The contra account depreciation accrues in (1290). */
@Column({ type: "uuid", name: "accumulated_account_id" })
accumulatedAccountId!: string;
/** Where the monthly charge is expensed (5400). */
@Column({ type: "uuid", name: "expense_account_id" })
expenseAccountId!: string;
@Column({ type: "int", name: "default_life_months" })
defaultLifeMonths!: number;
/** Fraction of cost expected to remain at the end of life, e.g. 0.05. */
@Column({
type: "numeric",
precision: 6,
scale: 4,
name: "default_salvage_rate",
default: 0,
})
defaultSalvageRate!: string;
@Column({ type: "boolean", name: "is_active", default: true })
isActive!: boolean;
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy?: string | null;
}
/**
* One item in the asset register.
*
* `accumulatedDepreciation` is a running total kept ON the asset. It duplicates
* what the ledger holds in the accumulated-depreciation account, which is
* normally the thing to avoid — it is here because depreciation must STOP at
* the depreciable base, and that is a per-asset decision made on every run.
* Deriving it from the ledger each time would be an aggregate query per asset
* per month. The ledger stays authoritative and the run reconciles against it.
*
* The database enforces the cap directly: `accumulated <= cost - salvage`. An
* arithmetic slip cannot over-depreciate an asset, only fail loudly.
*/
@Entity({ schema: "finance", name: "fixed_assets" })
@Index("idx_fixed_assets_status", ["status"])
@Index("idx_fixed_assets_category", ["assetCategoryId"])
@Index("idx_fixed_assets_cost_center", ["costCenterId"])
@Check(
"ck_fixed_assets_status",
`"status" IN ('ACTIVE','FULLY_DEPRECIATED','DISPOSED','WRITTEN_OFF')`,
)
@Check("ck_fixed_assets_method", `"depreciation_method" IN ('STRAIGHT_LINE')`)
@Check("ck_fixed_assets_cost", `"acquisition_cost" > 0`)
@Check("ck_fixed_assets_life", `"useful_life_months" > 0`)
@Check(
"ck_fixed_assets_salvage",
`"salvage_value" >= 0 AND "salvage_value" < "acquisition_cost"`,
)
@Check(
"ck_fixed_assets_accumulated",
`"accumulated_depreciation" >= 0
AND "accumulated_depreciation" <= "acquisition_cost" - "salvage_value"`,
)
@Check("ck_fixed_assets_in_service", `"in_service_date" >= "acquisition_date"`)
export class FixedAsset extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "uuid", name: "asset_category_id" })
assetCategoryId!: string;
@Column({ type: "varchar", length: 48, name: "asset_code" })
assetCode!: string;
@Column({ type: "varchar", length: 200, name: "name" })
name!: string;
@Column({ type: "text", name: "description", nullable: true })
description?: string | null;
@Column({ type: "varchar", length: 96, name: "serial_number", nullable: true })
serialNumber?: string | null;
@Column({ type: "uuid", name: "cost_center_id", nullable: true })
costCenterId?: string | null;
@Column({ type: "date", name: "acquisition_date" })
acquisitionDate!: string;
/**
* When the asset started being used — depreciation runs from HERE, not from
* acquisition. An asset bought in March and commissioned in June was not
* wearing out in between.
*/
@Column({ type: "date", name: "in_service_date" })
inServiceDate!: string;
@Column(moneyColumn({ name: "acquisition_cost" }))
acquisitionCost!: number;
@Column(moneyColumn({ name: "salvage_value", default: 0 }))
salvageValue!: number;
@Column({ type: "int", name: "useful_life_months" })
usefulLifeMonths!: number;
@Column({
type: "varchar",
length: 24,
name: "depreciation_method",
default: "STRAIGHT_LINE",
})
depreciationMethod!: DepreciationMethod;
@Column(moneyColumn({ name: "accumulated_depreciation", default: 0 }))
accumulatedDepreciation!: number;
/**
* Periods already charged BEFORE this asset reached Finance — the cutover
* count for an asset migrated mid-life. Zero for anything bought since.
*
* Depreciation counts periods from `depreciation_entries` rows, which a
* migrated asset has none of. Without this the count would read zero, the
* cumulative target would land below what is already accumulated, the charge
* would compute as negative and be skipped — and a skip writes no row, so the
* count could never grow and the asset would silently never depreciate again.
*/
@Column({
type: "int",
name: "opening_periods_charged",
default: 0,
})
openingPeriodsCharged!: number;
@Column({ type: "varchar", length: 16, name: "status", default: "ACTIVE" })
status!: AssetStatus;
/** The bill it was bought on, when it came through payables. */
@Column({ type: "uuid", name: "supplier_bill_id", nullable: true })
supplierBillId?: string | null;
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy?: string | null;
}
/** One month's depreciation across the register, posted as one journal entry. */
@Entity({ schema: "finance", name: "depreciation_runs" })
@Check("ck_depreciation_runs_total", `"total_amount" >= 0`)
export class DepreciationRun extends Audit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "uuid", name: "fiscal_period_id" })
fiscalPeriodId!: string;
@Column({ type: "date", name: "run_date" })
runDate!: string;
@Column({ type: "int", name: "asset_count", default: 0 })
assetCount!: number;
@Column(moneyColumn({ name: "total_amount", default: 0 }))
totalAmount!: number;
@Column({ type: "uuid", name: "journal_entry_id", nullable: true })
journalEntryId?: string | null;
@Column({ type: "uuid", name: "posted_by", nullable: true })
postedBy?: string | null;
}
/**
* What one asset was charged in one run.
*
* `accumulatedAfter` is stored so the register can be replayed: without it,
* reconstructing an asset's book value at a past date would mean re-deriving
* every prior run's arithmetic.
*/
@Entity({ schema: "finance", name: "depreciation_entries" })
@Index("idx_depreciation_entries_asset", ["fixedAssetId"])
@Check("ck_depreciation_entries_amount", `"amount" > 0`)
export class DepreciationEntry {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "depreciation_run_id" })
depreciationRunId!: string;
@Column({ type: "uuid", name: "fixed_asset_id" })
fixedAssetId!: string;
@Column(moneyColumn({ name: "amount" }))
amount!: number;
@Column(moneyColumn({ name: "accumulated_after" }))
accumulatedAfter!: number;
@Column({
type: "timestamptz",
name: "created_at",
default: () => "CURRENT_TIMESTAMP",
})
createdAt!: Date;
}
/**
* The end of an asset's life on the books.
*
* `gainLoss` is proceeds net book value: positive is a gain (the asset was
* worth less on paper than it sold for), negative a loss. Stored rather than
* derived because the net book value at the moment of disposal is a frozen
* fact, and later depreciation runs must not be able to change what a past
* disposal reported.
*/
@Entity({ schema: "finance", name: "asset_disposals" })
@Check(
"ck_asset_disposals_type",
`"disposal_type" IN ('SALE','SCRAP','WRITE_OFF')`,
)
@Check("ck_asset_disposals_proceeds", `"proceeds" >= 0`)
export class AssetDisposal extends Audit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "uuid", name: "fixed_asset_id" })
fixedAssetId!: string;
@Column({ type: "date", name: "disposal_date" })
disposalDate!: string;
@Column({ type: "varchar", length: 16, name: "disposal_type" })
disposalType!: DisposalType;
@Column(moneyColumn({ name: "proceeds", default: 0 }))
proceeds!: number;
@Column(moneyColumn({ name: "net_book_value" }))
netBookValue!: number;
@Column(moneyColumn({ name: "gain_loss" }))
gainLoss!: number;
/** Where the sale proceeds landed. Null for a scrap or write-off. */
@Column({ type: "uuid", name: "proceeds_account_id", nullable: true })
proceedsAccountId?: string | null;
@Column({ type: "varchar", length: 128, name: "reference", nullable: true })
reference?: string | null;
@Column({ type: "text", name: "notes", nullable: true })
notes?: string | null;
@Column({ type: "uuid", name: "journal_entry_id", nullable: true })
journalEntryId?: string | null;
@Column({ type: "uuid", name: "recorded_by", nullable: true })
recordedBy?: string | null;
}