migration

This commit is contained in:
s.esmaeili
2026-10-07 20:20:27 +03:30
parent 92831ed33e
commit 8c5d971d1f
17 changed files with 286 additions and 57 deletions
@@ -5,6 +5,7 @@ import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
import { faToEn } from '../customer-auth/digits';
import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
import { SmsService } from '../sms/sms.service';
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
@@ -67,6 +68,16 @@ export type OmdeScope = 'all' | 'banker' | 'shopkeeper' | 'non_verified' | 'arch
* `sts_verify_step_one !== 2`, i.e. only an ACCEPTED first step unlocks them.
* The new schema models one ordered enum, so these numbers are derived from it.
*/
/**
* A national code as the operator typed it, digits normalised; empty -> null.
* The column is optional and unique (2026-10-07): an absent code is NULL,
* never the mobile or '' — two customers without one must not collide, and
* the column must only ever hold a national code.
*/
function nationalCodeOrNull(raw: unknown): string | null {
return faToEn(String(raw ?? '').trim()) || null;
}
function verifyStepOne(step: VerificationStep): number {
switch (step) {
case VerificationStep.UNVERIFIED:
@@ -314,6 +325,8 @@ export class AdminCustomersService {
sts_verify_step_one: verifyStepOne(c.verificationStep),
sts_verify_step_two: verifyStepTwo(c.verificationStep),
verificationStep: c.verificationStep,
verification_rejection_reason: c.verificationRejectionReason,
verification_rejected_at: c.verificationRejectedAt,
national_id_image: c.nationalIdImageUrl,
business_license: c.businessLicenseUrl,
shop_image: c.shopImageUrl,
@@ -631,8 +644,9 @@ export class AdminCustomersService {
firstName: firstName || null,
lastName: lastName || null,
mobile,
nationalCode: mobile, // schema requires it and it is unique; singleCustomer.vue collects no ncode
passwordHash: '',
// singleCustomer.vue collects no national code.
nationalCode: null,
passwordHash: OTP_ONLY_PASSWORD_HASH,
cityId: (data.city_id as string) || null,
kind: CustomerKind.RETAIL,
},
@@ -660,7 +674,7 @@ export class AdminCustomersService {
// can never log in: the app's own faToEn turns the typed number into ASCII
// and the lookup misses.
const mobile = faToEn(String(data.mobile ?? '').trim());
const nationalCode = String(data.national_code ?? data.nationalCode ?? '').trim();
const nationalCode = nationalCodeOrNull(data.national_code ?? data.nationalCode);
if (!fullName || !mobile) {
throw new BadRequestException('نام و موبایل الزامی است');
@@ -691,12 +705,12 @@ export class AdminCustomersService {
// (AdminController.php:338) and is applied client-side.
makingChargeOffsetPercent: new Prisma.Decimal(String(data.percent)),
mobile,
nationalCode: nationalCode || mobile, // schema requires it and it is unique
nationalCode,
// The form collects a password (bulkCustomer.vue / ReagentCustomer.vue).
// It used to be discarded and '' stored, so the operator typed a password
// that silently did nothing. Hashed here when supplied; '' still means
// "no password yet", which no bcrypt hash can ever match.
passwordHash: data.password ? await bcrypt.hash(String(data.password), 10) : '',
// that silently did nothing. Hashed here when supplied; otherwise the
// account signs in by OTP only.
passwordHash: data.password ? await bcrypt.hash(String(data.password), 10) : OTP_ONLY_PASSWORD_HASH,
cityId: (data.city_id as string) ?? null,
goldCreditLimitGrams: data.gram_level
? new Prisma.Decimal(String(data.gram_level))
@@ -761,7 +775,7 @@ export class AdminCustomersService {
// that lock NOT ported, so the field stays editable here and is normalised
// instead. Logged in ISSUES as PERS-01.
if (data.mobile !== undefined) patch.mobile = faToEn(String(data.mobile));
if (data.national_code !== undefined) patch.nationalCode = String(data.national_code);
if (data.national_code !== undefined) patch.nationalCode = nationalCodeOrNull(data.national_code);
if (data.city_id !== undefined) {
patch.city = data.city_id ? { connect: { id: String(data.city_id) } } : { disconnect: true };
}
@@ -950,35 +964,50 @@ export class AdminCustomersService {
* Legacy's payload is `{ customer_id, sts_verify_step_one }` or
* `{ customer_id, sts_verify_step_two }`, where the value is 2 (accept) or
* 3 (reject) — see OmdeList.vue acceptStepOne/rejectStepOne. Accepted here in
* exactly that shape so the ported view posts unchanged.
* exactly that shape so the ported view posts unchanged, plus an optional
* `reason` on a reject.
*
* A reject at either step records REJECTED with its reason and time (owner
* ruling 2026-10-07). It used to send the customer back to UNVERIFIED /
* STEP_ONE_SUBMITTED, so a rejection left no trace and looked like a
* customer who had simply not finished. Same rule as the legacy import:
* a rejected first OR second step is REJECTED (DATA-MIGRATION-MAP §5.5).
* Accepting again clears the reason.
*/
async verify(payload: {
customer_id: string;
sts_verify_step_one?: number;
sts_verify_step_two?: number;
reason?: string | null;
}) {
const id = payload.customer_id;
const c = await this.prisma.customer.findUnique({ where: { id } });
if (!c) throw new NotFoundException('مشتری یافت نشد');
let next: VerificationStep;
const step = payload.sts_verify_step_one ?? payload.sts_verify_step_two;
if (step === undefined) throw new BadRequestException('مرحله‌ی تایید مشخص نشده است');
if (payload.sts_verify_step_one !== undefined) {
next =
payload.sts_verify_step_one === 2
? VerificationStep.STEP_TWO_SUBMITTED // step one accepted -> step two pending
: VerificationStep.UNVERIFIED; // rejected
} else if (payload.sts_verify_step_two !== undefined) {
next =
payload.sts_verify_step_two === 2
? VerificationStep.VERIFIED
: VerificationStep.STEP_ONE_SUBMITTED; // rejected -> back to step one accepted
let data: Prisma.CustomerUpdateInput;
if (step === 2) {
data = {
verificationStep:
payload.sts_verify_step_one !== undefined
? VerificationStep.STEP_TWO_SUBMITTED // step one accepted -> step two pending
: VerificationStep.VERIFIED,
verificationRejectionReason: null,
verificationRejectedAt: null,
};
} else {
throw new BadRequestException('مرحله‌ی تایید مشخص نشده است');
data = {
verificationStep: VerificationStep.REJECTED,
verificationRejectionReason: String(payload.reason ?? '').trim() || null,
verificationRejectedAt: new Date(),
};
}
const next = data.verificationStep;
return this.prisma.$transaction(async (tx) => {
const updated = await tx.customer.update({ where: { id }, data: { verificationStep: next } });
const updated = await tx.customer.update({ where: { id }, data });
// D27 — a verified customer gets an account in the accounting software
// (queued; searched by mobile first, so an existing account is linked).
if (next === VerificationStep.VERIFIED && c.verificationStep !== VerificationStep.VERIFIED) {