migration
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user