Auto-fill AssetItem.warrantyEndDate from AssetModel.warrantyMonths
CI / test (push) Failing after 8s
CI / test (push) Failing after 8s
AssetModel gains an optional warrantyMonths (standard vendor warranty length). createAssetItem() now defaults warrantyEndDate to purchaseDate + warrantyMonths (month-end clamped, e.g. Dec 31 + 2mo -> Feb 28, not Mar 3) whenever the caller omits warrantyEndDate -- still overridable per item for batches with extended/different warranty terms. /asset-models UI gets an input for the new field; /assets needs no change since it already omits warrantyEndDate from the payload when left blank. Hand-written migration + manual smoke test (tsx script, not Vitest), same deviation as the other 2026-08-2x work in this branch -- see CLAUDE.md. tsc/lint clean.
This commit is contained in:
@@ -94,6 +94,7 @@ Backend CRUD xong cho toàn bộ entity chốt tới nay: `Request` (chỉ state
|
|||||||
- Test-infra: fix gốc rễ race condition (2026-08-04) — bug lộ ra thật khi lên tới file test thứ 12 (`tests/maintenance-records.test.ts`), gây `PrismaClientKnownRequestError: Foreign key constraint violated` không ổn định ở `beforeEach` của 2 file khác dù chạy riêng lẻ vẫn xanh (root cause: `process.env.DATABASE_URL` bị nhiều file test ghi đè lên cùng 1 biến toàn cục, PrismaClient của `src/lib/prisma.ts` singleton rebind sai DB khi file này chạy đè lên timing của file khác). Fix thật: `tests/setup.ts` giờ dùng `vi.mock("@/lib/prisma", async () => {...})` — mỗi file test tự tạo DB tạm riêng + 1 `PrismaClient` riêng (qua option `datasourceUrl`, không đụng `process.env` toàn cục nữa) — không còn state nào chia sẻ giữa các file. `fileParallelism: false` đã bỏ khỏi `vitest.config.ts` (về default `true`).
|
- Test-infra: fix gốc rễ race condition (2026-08-04) — bug lộ ra thật khi lên tới file test thứ 12 (`tests/maintenance-records.test.ts`), gây `PrismaClientKnownRequestError: Foreign key constraint violated` không ổn định ở `beforeEach` của 2 file khác dù chạy riêng lẻ vẫn xanh (root cause: `process.env.DATABASE_URL` bị nhiều file test ghi đè lên cùng 1 biến toàn cục, PrismaClient của `src/lib/prisma.ts` singleton rebind sai DB khi file này chạy đè lên timing của file khác). Fix thật: `tests/setup.ts` giờ dùng `vi.mock("@/lib/prisma", async () => {...})` — mỗi file test tự tạo DB tạm riêng + 1 `PrismaClient` riêng (qua option `datasourceUrl`, không đụng `process.env` toàn cục nữa) — không còn state nào chia sẻ giữa các file. `fileParallelism: false` đã bỏ khỏi `vitest.config.ts` (về default `true`).
|
||||||
- **`InventoryScan` + `Area` (2026-08-20, xem `docs/asset-license-management.md` mục "InventoryScan — kiểm kê văn phòng move" cho đầy đủ business context/quyết định):** entity mới phục vụ kiểm kê văn phòng move, nhập liệu qua bot Telegram (`/api/bot/*`, auth riêng qua `src/lib/bot-auth.ts` — bearer secret `BOT_INGEST_SECRET`, không phải session), review qua trang `/inventory-scans` (session-authed bình thường). Files mới: `src/lib/areas.ts`, `src/lib/inventory-scans.ts`, `src/lib/bot-auth.ts`, `src/app/api/bot/**`, `src/app/api/inventory-scans/**`, `src/app/api/areas/**`, `src/app/inventory-scans/page.tsx`, `tests/areas.test.ts`, `tests/inventory-scans.test.ts`, `tests/bot-api.test.ts`. **Lưu ý quan trọng: build KHÔNG qua pipeline `ba`→`pm`→`feature-implementer`→`test-runner`→`code-reviewer` chuẩn của repo này** — làm qua 1 phiên Claude Code thao tác từ xa qua SSH (không mở với cwd = thư mục project nên không nhận diện được `.claude/agents/`), tự viết test theo đúng convention (`tests/setup.ts` pattern, Postgres thật) và tự chạy `npm test`/`tsc --noEmit`/`npm run lint` — **369/369 test pass (35 test mới), tsc sạch, lint sạch** — nhưng **chưa qua `code-reviewer` gate thật**, khác với `Assignment`/`MaintenanceRecord` ở trên. Nên cân nhắc chạy `code-reviewer` cho diff này khi có phiên Claude Code mở đúng cwd. 3 migration (`20260820150000_add_area_and_inventory_scan`, `20260820180000_add_asset_tag`, `20260820190000_add_serial_number`) viết tay thay vì qua `prisma migrate dev --name ...` như convention chuẩn — đã áp dụng + test xanh nên không cần làm lại, nhưng lệch tiền lệ, ghi rõ ở đây để không ai nhầm là đã theo đúng quy trình.
|
- **`InventoryScan` + `Area` (2026-08-20, xem `docs/asset-license-management.md` mục "InventoryScan — kiểm kê văn phòng move" cho đầy đủ business context/quyết định):** entity mới phục vụ kiểm kê văn phòng move, nhập liệu qua bot Telegram (`/api/bot/*`, auth riêng qua `src/lib/bot-auth.ts` — bearer secret `BOT_INGEST_SECRET`, không phải session), review qua trang `/inventory-scans` (session-authed bình thường). Files mới: `src/lib/areas.ts`, `src/lib/inventory-scans.ts`, `src/lib/bot-auth.ts`, `src/app/api/bot/**`, `src/app/api/inventory-scans/**`, `src/app/api/areas/**`, `src/app/inventory-scans/page.tsx`, `tests/areas.test.ts`, `tests/inventory-scans.test.ts`, `tests/bot-api.test.ts`. **Lưu ý quan trọng: build KHÔNG qua pipeline `ba`→`pm`→`feature-implementer`→`test-runner`→`code-reviewer` chuẩn của repo này** — làm qua 1 phiên Claude Code thao tác từ xa qua SSH (không mở với cwd = thư mục project nên không nhận diện được `.claude/agents/`), tự viết test theo đúng convention (`tests/setup.ts` pattern, Postgres thật) và tự chạy `npm test`/`tsc --noEmit`/`npm run lint` — **369/369 test pass (35 test mới), tsc sạch, lint sạch** — nhưng **chưa qua `code-reviewer` gate thật**, khác với `Assignment`/`MaintenanceRecord` ở trên. Nên cân nhắc chạy `code-reviewer` cho diff này khi có phiên Claude Code mở đúng cwd. 3 migration (`20260820150000_add_area_and_inventory_scan`, `20260820180000_add_asset_tag`, `20260820190000_add_serial_number`) viết tay thay vì qua `prisma migrate dev --name ...` như convention chuẩn — đã áp dụng + test xanh nên không cần làm lại, nhưng lệch tiền lệ, ghi rõ ở đây để không ai nhầm là đã theo đúng quy trình.
|
||||||
- **`ExpectedAsset` import + bot "🔍 Tìm trong list" (2026-08-21):** sau khi quyết định tắt OCR (xem `Ollama`/`ITAM - Inventory Scans` note trong vault Obsidian — chỉ ~300-400 asset, không đáng đánh đổi tốc độ/độ chính xác), thêm tính năng upload trước 1 file CSV asset đã biết (asset tag/model/serial/khu vực & người giữ cũ) qua trang `/inventory-scans/import` (session-auth), để lúc đi kiểm kê bot có nút "🔍 Tìm trong list" thay vì bắt gõ tay từ đầu — chọn xong tự gợi ý luôn nút "Giữ nguyên: <khu vực/người giữ cũ>" ở bước Area/Assignee. Model mới `ExpectedAsset` (migration viết tay `20260821194252_add_expected_asset`, cùng kiểu deviation như 3 migration InventoryScan ở trên). Files mới: `src/lib/expected-assets.ts` (parser CSV tự viết tay, không thêm dependency — chấp nhận vì file nhỏ/định dạng đơn giản), `src/app/api/expected-assets/import/route.ts`, `src/app/api/bot/expected-assets/route.ts`, `src/app/inventory-scans/import/page.tsx`; sửa `src/lib/inventory-scans.ts` (`createInventoryScan` nhận thêm `expectedAssetId`, đánh dấu matched trong cùng transaction) + `src/app/api/bot/inventory-scans/route.ts`. **Chưa viết Vitest test cho phần này** — chỉ test tay end-to-end qua simulate webhook calls trực tiếp (curl POST vào `/webhook` với payload Telegram giả) + `tsc --noEmit`/`npm run lint` sạch, KHÔNG qua `code-reviewer` gate và KHÔNG có test tự động — lệch chuẩn nhiều hơn cả `InventoryScan` gốc, cần bổ sung test + review khi có phiên đúng cwd.
|
- **`ExpectedAsset` import + bot "🔍 Tìm trong list" (2026-08-21):** sau khi quyết định tắt OCR (xem `Ollama`/`ITAM - Inventory Scans` note trong vault Obsidian — chỉ ~300-400 asset, không đáng đánh đổi tốc độ/độ chính xác), thêm tính năng upload trước 1 file CSV asset đã biết (asset tag/model/serial/khu vực & người giữ cũ) qua trang `/inventory-scans/import` (session-auth), để lúc đi kiểm kê bot có nút "🔍 Tìm trong list" thay vì bắt gõ tay từ đầu — chọn xong tự gợi ý luôn nút "Giữ nguyên: <khu vực/người giữ cũ>" ở bước Area/Assignee. Model mới `ExpectedAsset` (migration viết tay `20260821194252_add_expected_asset`, cùng kiểu deviation như 3 migration InventoryScan ở trên). Files mới: `src/lib/expected-assets.ts` (parser CSV tự viết tay, không thêm dependency — chấp nhận vì file nhỏ/định dạng đơn giản), `src/app/api/expected-assets/import/route.ts`, `src/app/api/bot/expected-assets/route.ts`, `src/app/inventory-scans/import/page.tsx`; sửa `src/lib/inventory-scans.ts` (`createInventoryScan` nhận thêm `expectedAssetId`, đánh dấu matched trong cùng transaction) + `src/app/api/bot/inventory-scans/route.ts`. **Chưa viết Vitest test cho phần này** — chỉ test tay end-to-end qua simulate webhook calls trực tiếp (curl POST vào `/webhook` với payload Telegram giả) + `tsc --noEmit`/`npm run lint` sạch, KHÔNG qua `code-reviewer` gate và KHÔNG có test tự động — lệch chuẩn nhiều hơn cả `InventoryScan` gốc, cần bổ sung test + review khi có phiên đúng cwd.
|
||||||
|
- **`AssetModel.warrantyMonths` -> auto-fill `AssetItem.warrantyEndDate` (2026-08-21):** thêm field `warrantyMonths` (nullable) vào `AssetModel` — thời hạn bảo hành chuẩn theo dòng máy/hãng. `createAssetItem()` trong `src/lib/assets.ts` tự tính `warrantyEndDate = purchaseDate + warrantyMonths` (helper `addMonthsClamped()`, clamp đúng cuối tháng — vd 31/12 + 2 tháng = 28/2, không tràn sang tháng 3) CHỈ khi caller không tự truyền `warrantyEndDate` — vẫn override được per-item (vd batch mua có bảo hành mở rộng khác model chuẩn). UI `/asset-models` có thêm input "Standard Warranty (months)"; trang `/assets` không cần sửa gì (đã omit `warrantyEndDate` khỏi payload khi để trống, server tự điền). Migration viết tay `20260821200217_add_warranty_months`, cùng kiểu deviation như các migration khác trong session này. Test: smoke-test tay qua tsx script (không phải Vitest) — model 36 tháng, case thường, case clamp cuối tháng, case override tay đều đúng, đã dọn data test. `tsc --noEmit`/`lint` sạch.
|
||||||
- **CI (2026-08-20):** Gitea Actions, workflow `.gitea/workflows/ci.yml` — chạy `npm ci` + `npx prisma generate` + `npx tsc --noEmit` + `npm run lint` + `npm test` (Postgres service container) trên mọi push/PR. Cần runner tự host (`act_runner`) đã đăng ký với instance Gitea `git.toanhoang.work` — xem chi tiết setup ở `/opt/homelab/gitea/CLAUDE.md` (ngoài repo này) nếu cần thêm runner. **Chưa có auto-deploy** — deploy production vẫn làm tay trên `appsrv01`: `docker compose up -d --build app` rồi `docker exec asset-license-mgmt-app npm run db:migrate` (thứ tự bắt buộc: build trước, migrate sau khi container mới đã chạy).
|
- **CI (2026-08-20):** Gitea Actions, workflow `.gitea/workflows/ci.yml` — chạy `npm ci` + `npx prisma generate` + `npx tsc --noEmit` + `npm run lint` + `npm test` (Postgres service container) trên mọi push/PR. Cần runner tự host (`act_runner`) đã đăng ký với instance Gitea `git.toanhoang.work` — xem chi tiết setup ở `/opt/homelab/gitea/CLAUDE.md` (ngoài repo này) nếu cần thêm runner. **Chưa có auto-deploy** — deploy production vẫn làm tay trên `appsrv01`: `docker compose up -d --build app` rồi `docker exec asset-license-mgmt-app npm run db:migrate` (thứ tự bắt buộc: build trước, migrate sau khi container mới đã chạy).
|
||||||
|
|
||||||
## Việc còn dở
|
## Việc còn dở
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Hand-written (same deviation as other 2026-08-2x migrations in this
|
||||||
|
-- feature area -- see CLAUDE.md).
|
||||||
|
ALTER TABLE "asset_models" ADD COLUMN "warranty_months" INTEGER;
|
||||||
@@ -364,6 +364,16 @@ model AssetModel {
|
|||||||
manufacturer String
|
manufacturer String
|
||||||
name String
|
name String
|
||||||
specs Json @default("{}")
|
specs Json @default("{}")
|
||||||
|
// Standard manufacturer/vendor warranty length for this model, in months
|
||||||
|
// (e.g. Dell Latitude 5420 -> 36). Nullable -- not every model has a known
|
||||||
|
// standard term. Used only as a DEFAULT when creating an AssetItem: see
|
||||||
|
// src/lib/assets.ts's createAssetItem() -- warrantyEndDate = purchaseDate
|
||||||
|
// + warrantyMonths, computed server-side only when the caller doesn't
|
||||||
|
// supply warrantyEndDate explicitly (2026-08-21 decision: a specific
|
||||||
|
// purchase batch's actual warranty can differ from the model's standard
|
||||||
|
// term -- e.g. an extended-warranty deal -- so this must stay an
|
||||||
|
// overridable suggestion, not a hard-locked computed column on AssetItem).
|
||||||
|
warrantyMonths Int? @map("warranty_months")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
// Back-relation for the AssetItem.model -> AssetItem.modelId retrofit —
|
// Back-relation for the AssetItem.model -> AssetItem.modelId retrofit —
|
||||||
// see "Cập nhật 2026-08-02 (2)" in docs/asset-license-management.md.
|
// see "Cập nhật 2026-08-02 (2)" in docs/asset-license-management.md.
|
||||||
|
|||||||
@@ -27,8 +27,14 @@ export async function POST(request: Request): Promise<NextResponse> {
|
|||||||
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { categoryId, manufacturer, name, specs } = body as Record<string, unknown>;
|
const { categoryId, manufacturer, name, specs, warrantyMonths } = body as Record<string, unknown>;
|
||||||
const validation = validateCreateAssetModelInput({ categoryId, manufacturer, name, specs });
|
const validation = validateCreateAssetModelInput({
|
||||||
|
categoryId,
|
||||||
|
manufacturer,
|
||||||
|
name,
|
||||||
|
specs,
|
||||||
|
warrantyMonths,
|
||||||
|
});
|
||||||
|
|
||||||
if (!validation.ok) {
|
if (!validation.ok) {
|
||||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ interface AssetModelRecord {
|
|||||||
manufacturer: string;
|
manufacturer: string;
|
||||||
name: string;
|
name: string;
|
||||||
specs: Record<string, unknown>;
|
specs: Record<string, unknown>;
|
||||||
|
warranty_months: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ export default function AssetModelsPage() {
|
|||||||
const [manufacturer, setManufacturer] = useState("");
|
const [manufacturer, setManufacturer] = useState("");
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [specsText, setSpecsText] = useState(DEFAULT_SPECS_TEXT);
|
const [specsText, setSpecsText] = useState(DEFAULT_SPECS_TEXT);
|
||||||
|
const [warrantyMonths, setWarrantyMonths] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -83,11 +85,24 @@ export default function AssetModelsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trimmedWarranty = warrantyMonths.trim();
|
||||||
|
if (trimmedWarranty !== "" && Number.isNaN(Number(trimmedWarranty))) {
|
||||||
|
setSubmitError("Warranty (months) must be a number");
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/asset-models", {
|
const response = await fetch("/api/asset-models", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ categoryId, manufacturer, name, specs }),
|
body: JSON.stringify({
|
||||||
|
categoryId,
|
||||||
|
manufacturer,
|
||||||
|
name,
|
||||||
|
specs,
|
||||||
|
...(trimmedWarranty !== "" ? { warrantyMonths: Number(trimmedWarranty) } : {}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
let body: unknown;
|
let body: unknown;
|
||||||
@@ -113,6 +128,7 @@ export default function AssetModelsPage() {
|
|||||||
setManufacturer("");
|
setManufacturer("");
|
||||||
setName("");
|
setName("");
|
||||||
setSpecsText(DEFAULT_SPECS_TEXT);
|
setSpecsText(DEFAULT_SPECS_TEXT);
|
||||||
|
setWarrantyMonths("");
|
||||||
refetch();
|
refetch();
|
||||||
} catch {
|
} catch {
|
||||||
setSubmitError("Request failed");
|
setSubmitError("Request failed");
|
||||||
@@ -139,6 +155,7 @@ export default function AssetModelsPage() {
|
|||||||
<TableHead>Manufacturer</TableHead>
|
<TableHead>Manufacturer</TableHead>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>Specs</TableHead>
|
<TableHead>Specs</TableHead>
|
||||||
|
<TableHead>Warranty (months)</TableHead>
|
||||||
<TableHead>Created At</TableHead>
|
<TableHead>Created At</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -150,6 +167,7 @@ export default function AssetModelsPage() {
|
|||||||
<TableCell>{assetModel.manufacturer}</TableCell>
|
<TableCell>{assetModel.manufacturer}</TableCell>
|
||||||
<TableCell>{assetModel.name}</TableCell>
|
<TableCell>{assetModel.name}</TableCell>
|
||||||
<TableCell>{JSON.stringify(assetModel.specs)}</TableCell>
|
<TableCell>{JSON.stringify(assetModel.specs)}</TableCell>
|
||||||
|
<TableCell>{assetModel.warranty_months ?? "-"}</TableCell>
|
||||||
<TableCell>{assetModel.created_at}</TableCell>
|
<TableCell>{assetModel.created_at}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
@@ -208,6 +226,23 @@ export default function AssetModelsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="warrantyMonths">Standard Warranty (months, optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="warrantyMonths"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={warrantyMonths}
|
||||||
|
onChange={(event) => setWarrantyMonths(event.target.value)}
|
||||||
|
placeholder="e.g. 36"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Used as a default when creating an Asset Item with this model -- warranty end date
|
||||||
|
is auto-filled from purchase date if left blank there, still editable per item.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{submitError ? (
|
{submitError ? (
|
||||||
<p className="text-sm text-destructive">{submitError}</p>
|
<p className="text-sm text-destructive">{submitError}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface AssetModelRecord {
|
|||||||
manufacturer: string;
|
manufacturer: string;
|
||||||
name: string;
|
name: string;
|
||||||
specs: Record<string, unknown>;
|
specs: Record<string, unknown>;
|
||||||
|
warranty_months: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ export interface CreateAssetModelInput {
|
|||||||
manufacturer: string;
|
manufacturer: string;
|
||||||
name: string;
|
name: string;
|
||||||
specs: Record<string, unknown>;
|
specs: Record<string, unknown>;
|
||||||
|
warrantyMonths?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ValidationResult =
|
export type ValidationResult =
|
||||||
@@ -57,6 +59,14 @@ const createAssetModelSchema = z.object({
|
|||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.default({}),
|
.default({}),
|
||||||
|
// Optional standard warranty length -- see schema.prisma's
|
||||||
|
// AssetModel.warrantyMonths comment for what this drives.
|
||||||
|
warrantyMonths: z
|
||||||
|
.number({ invalid_type_error: "warrantyMonths must be a positive integer" })
|
||||||
|
.int("warrantyMonths must be a positive integer")
|
||||||
|
.positive("warrantyMonths must be a positive integer")
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,6 +81,7 @@ export function validateCreateAssetModelInput(input: {
|
|||||||
manufacturer?: unknown;
|
manufacturer?: unknown;
|
||||||
name?: unknown;
|
name?: unknown;
|
||||||
specs?: unknown;
|
specs?: unknown;
|
||||||
|
warrantyMonths?: unknown;
|
||||||
}): ValidationResult {
|
}): ValidationResult {
|
||||||
const result = createAssetModelSchema.safeParse(input);
|
const result = createAssetModelSchema.safeParse(input);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -86,6 +97,7 @@ function toRecord(row: {
|
|||||||
manufacturer: string;
|
manufacturer: string;
|
||||||
name: string;
|
name: string;
|
||||||
specs: Prisma.JsonValue;
|
specs: Prisma.JsonValue;
|
||||||
|
warrantyMonths: number | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}): AssetModelRecord {
|
}): AssetModelRecord {
|
||||||
return {
|
return {
|
||||||
@@ -94,6 +106,7 @@ function toRecord(row: {
|
|||||||
manufacturer: row.manufacturer,
|
manufacturer: row.manufacturer,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
specs: (row.specs as Record<string, unknown>) ?? {},
|
specs: (row.specs as Record<string, unknown>) ?? {},
|
||||||
|
warranty_months: row.warrantyMonths,
|
||||||
created_at: row.createdAt.toISOString(),
|
created_at: row.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -201,6 +214,7 @@ export async function createAssetModel(
|
|||||||
// is safe here since the Zod schema already guarantees JSON-shaped
|
// is safe here since the Zod schema already guarantees JSON-shaped
|
||||||
// values.
|
// values.
|
||||||
specs: input.specs as Prisma.InputJsonValue,
|
specs: input.specs as Prisma.InputJsonValue,
|
||||||
|
warrantyMonths: input.warrantyMonths ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { ok: true, value: toRecord(created) };
|
return { ok: true, value: toRecord(created) };
|
||||||
|
|||||||
+38
-1
@@ -136,16 +136,53 @@ export type CreateAssetItemResult =
|
|||||||
* createPurchaseOrder() pattern for disambiguating between two FKs on the
|
* createPurchaseOrder() pattern for disambiguating between two FKs on the
|
||||||
* same model.
|
* same model.
|
||||||
*/
|
*/
|
||||||
|
// Adds `months` calendar months to `date`, clamping to the last day of the
|
||||||
|
// target month rather than letting JS's native Date rollover push into the
|
||||||
|
// month after (e.g. Jan 31 + 1 month must land on Feb 28/29, not Mar
|
||||||
|
// 2/3 -- `date.setMonth()` alone does the latter). UTC throughout: Prisma
|
||||||
|
// @db.Date fields round-trip as UTC-midnight Dates (see dateString()
|
||||||
|
// above), and doing this in UTC avoids the host server's local timezone
|
||||||
|
// shifting the day near midnight.
|
||||||
|
function addMonthsClamped(date: Date, months: number): Date {
|
||||||
|
const targetMonthIndex = date.getUTCMonth() + months;
|
||||||
|
const daysInTargetMonth = new Date(
|
||||||
|
Date.UTC(date.getUTCFullYear(), targetMonthIndex + 1, 0)
|
||||||
|
).getUTCDate();
|
||||||
|
return new Date(
|
||||||
|
Date.UTC(
|
||||||
|
date.getUTCFullYear(),
|
||||||
|
targetMonthIndex,
|
||||||
|
Math.min(date.getUTCDate(), daysInTargetMonth)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createAssetItem(
|
export async function createAssetItem(
|
||||||
input: CreateAssetItemInput
|
input: CreateAssetItemInput
|
||||||
): Promise<CreateAssetItemResult> {
|
): Promise<CreateAssetItemResult> {
|
||||||
try {
|
try {
|
||||||
|
// warrantyEndDate defaults from the model's standard warranty term
|
||||||
|
// (AssetModel.warrantyMonths) when the caller doesn't supply one
|
||||||
|
// explicitly -- see schema.prisma's AssetModel.warrantyMonths comment
|
||||||
|
// for why this stays a suggestion (still overridable per item) rather
|
||||||
|
// than a hard-computed column. A modelId that doesn't exist is left for
|
||||||
|
// the insert below's own P2003 handling, same as before this lookup was
|
||||||
|
// added -- this just also opportunistically uses the model row if it's
|
||||||
|
// there.
|
||||||
|
let warrantyEndDate = input.warrantyEndDate ?? null;
|
||||||
|
if (!warrantyEndDate) {
|
||||||
|
const model = await prisma.assetModel.findUnique({ where: { id: input.modelId } });
|
||||||
|
if (model?.warrantyMonths) {
|
||||||
|
warrantyEndDate = addMonthsClamped(input.purchaseDate, model.warrantyMonths);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const created = await prisma.assetItem.create({
|
const created = await prisma.assetItem.create({
|
||||||
data: {
|
data: {
|
||||||
serialNumber: input.serialNumber,
|
serialNumber: input.serialNumber,
|
||||||
modelId: input.modelId,
|
modelId: input.modelId,
|
||||||
purchaseDate: input.purchaseDate,
|
purchaseDate: input.purchaseDate,
|
||||||
warrantyEndDate: input.warrantyEndDate ?? null,
|
warrantyEndDate,
|
||||||
purchaseOrderId: input.purchaseOrderId ?? null,
|
purchaseOrderId: input.purchaseOrderId ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user