migration
This commit is contained in:
@@ -127,7 +127,12 @@
|
||||
class="cursor-pointer w-5 ml-1"
|
||||
:class="stepIconClass(getStepOneIcon(record))"
|
||||
/>
|
||||
<div>{{ getStepOneText(record) }}</div>
|
||||
<div>
|
||||
<div>{{ getStepOneText(record) }}</div>
|
||||
<div v-if="record.verificationStep === 'REJECTED'" class="text-danger text-xs">
|
||||
رد شده{{ record.verification_rejection_reason ? `: ${record.verification_rejection_reason}` : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-popconfirm>
|
||||
</a-menu-item>
|
||||
@@ -213,6 +218,26 @@
|
||||
</template>
|
||||
</XTable>
|
||||
|
||||
<!-- Reject = REJECTED with an optional reason (owner ruling 2026-10-07). -->
|
||||
<a-modal
|
||||
:open="rejectDialog.open"
|
||||
title="رد مدارک مشتری"
|
||||
ok-text="ثبت رد"
|
||||
cancel-text="انصراف"
|
||||
:ok-button-props="{ danger: true }"
|
||||
:confirm-loading="rejectDialog.saving"
|
||||
@ok="submitReject"
|
||||
@cancel="rejectDialog.open = false"
|
||||
>
|
||||
<p>دلیل رد (اختیاری) — در پروندهی مشتری ثبت میشود.</p>
|
||||
<a-textarea
|
||||
v-model:value="rejectDialog.reason"
|
||||
:rows="3"
|
||||
:maxlength="500"
|
||||
placeholder="مثلاً: تصویر کارت ملی خوانا نیست"
|
||||
/>
|
||||
</a-modal>
|
||||
|
||||
<BulkCustomer
|
||||
v-if="showAddButton"
|
||||
:key="editingId ?? 'new'"
|
||||
@@ -365,12 +390,38 @@ async function postVerify(payload: Record<string, unknown>, errorMsg: string) {
|
||||
|
||||
const acceptStepOne = (id: string) =>
|
||||
postVerify({ customer_id: id, sts_verify_step_one: 2 }, 'خطا در تغییر وضعیت اولیه مشتری');
|
||||
const rejectStepOne = (id: string) =>
|
||||
postVerify({ customer_id: id, sts_verify_step_one: 3 }, 'خطا در تغییر وضعیت اولیه مشتری');
|
||||
const rejectStepOne = (id: string) => openReject(id, 'sts_verify_step_one');
|
||||
const acceptStepTwo = (id: string) =>
|
||||
postVerify({ customer_id: id, sts_verify_step_two: 2 }, 'خطا در تغییر وضعیت سفارش مشتری');
|
||||
const rejectStepTwo = (id: string) =>
|
||||
postVerify({ customer_id: id, sts_verify_step_two: 3 }, 'خطا در تغییر وضعیت سفارش مشتری');
|
||||
const rejectStepTwo = (id: string) => openReject(id, 'sts_verify_step_two');
|
||||
|
||||
/**
|
||||
* A reject asks for an optional reason first; the customer is then stored as
|
||||
* REJECTED with it (admin-customers.service.ts verify()).
|
||||
*/
|
||||
type VerifyStep = 'sts_verify_step_one' | 'sts_verify_step_two';
|
||||
const rejectDialog = ref<{ open: boolean; saving: boolean; id: string; step: VerifyStep; reason: string }>({
|
||||
open: false,
|
||||
saving: false,
|
||||
id: '',
|
||||
step: 'sts_verify_step_one',
|
||||
reason: '',
|
||||
});
|
||||
|
||||
function openReject(id: string, step: VerifyStep) {
|
||||
rejectDialog.value = { open: true, saving: false, id, step, reason: '' };
|
||||
}
|
||||
|
||||
async function submitReject() {
|
||||
const d = rejectDialog.value;
|
||||
d.saving = true;
|
||||
await postVerify(
|
||||
{ customer_id: d.id, [d.step]: 3, reason: d.reason.trim() || null },
|
||||
d.step === 'sts_verify_step_one' ? 'خطا در تغییر وضعیت اولیه مشتری' : 'خطا در تغییر وضعیت سفارش مشتری',
|
||||
);
|
||||
d.saving = false;
|
||||
d.open = false;
|
||||
}
|
||||
|
||||
/** legacy inline ternary on @confirm, preserved including the NonVerified quirk */
|
||||
function onConfirmStepOne(record: Record<string, any>) {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
-- ============================================================================
|
||||
-- Customer identity cleanup — data only, no schema change. Idempotent.
|
||||
--
|
||||
-- Owner ruling 2026-10-07: «هر دیتایی باید سر جای خودش بشینه».
|
||||
--
|
||||
-- 1. nationalCode held the MOBILE (or "<mobile>-<provider>-<code>") on every
|
||||
-- customer created without a national code, because the column used to be
|
||||
-- NOT NULL. It is optional since 20261007120000, and the code paths that
|
||||
-- wrote those stand-ins are fixed in the same change as this file. The
|
||||
-- stand-ins are cleared to NULL; a real national code is never touched.
|
||||
-- Accounting is unaffected: it only ever received validNationalCode(),
|
||||
-- which already mapped a mobile to null.
|
||||
--
|
||||
-- 2. passwordHash '' becomes the explicit "no password login" marker
|
||||
-- (auth/password-hash.ts). Both are refused before bcrypt by
|
||||
-- compareLocalHash, so nobody's ability to sign in changes.
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE "Customer"
|
||||
SET "nationalCode" = NULL
|
||||
WHERE "nationalCode" = ''
|
||||
OR "nationalCode" = "mobile"
|
||||
OR "nationalCode" LIKE "mobile" || '-%';
|
||||
|
||||
UPDATE "Customer" SET "passwordHash" = '!OTP_ONLY_NO_PASSWORD_LOGIN' WHERE "passwordHash" = '';
|
||||
UPDATE "AdminUser" SET "passwordHash" = '!NO_PASSWORD_SET_YET' WHERE "passwordHash" = '';
|
||||
|
||||
-- The earlier comment named the wrong source table: `fav` belongs to the retail
|
||||
-- shop; wholesale favourites come from `major_favorites` (DATA-MIGRATION-MAP §10.7).
|
||||
COMMENT ON TABLE "CustomerFavorite" IS
|
||||
'Legacy `major_favorites` (wholesale; deduplicated on import). Composite PK replaces the source table''s missing primary key. Derive a favourite count from here rather than storing a counter.';
|
||||
@@ -116,7 +116,7 @@ async function main() {
|
||||
const customer = await prisma.customer.upsert({
|
||||
where: { mobile },
|
||||
update: {},
|
||||
create: { fullName: 'gold-bank smoke', firstName: 'gold-bank', lastName: 'smoke', mobile, nationalCode: mobile, passwordHash: '' },
|
||||
create: { fullName: 'gold-bank smoke', firstName: 'gold-bank', lastName: 'smoke', mobile, nationalCode: null, passwordHash: '!OTP_ONLY_NO_PASSWORD_LOGIN' },
|
||||
});
|
||||
await prisma.customer.update({ where: { id: customer.id }, data: { verificationStep: VerificationStep.STEP_TWO_SUBMITTED } });
|
||||
await customersAdmin.verify({ customer_id: customer.id, sts_verify_step_two: 2 });
|
||||
@@ -138,7 +138,7 @@ async function main() {
|
||||
const imported = await prisma.accountingLink.findFirst({ where: { origin: 'IMPORTED' } });
|
||||
if (imported) {
|
||||
const c = await prisma.customer.findUnique({ where: { id: imported.entityId } });
|
||||
chk(c?.mobile?.startsWith('09') && c.passwordHash === '' && c.goldCreditLimitGrams.eq(0), 'imported customer keyed by mobile, OTP-only, credit 0');
|
||||
chk(c?.mobile?.startsWith('09') && c.passwordHash === '!OTP_ONLY_NO_PASSWORD_LOGIN' && c.goldCreditLimitGrams.eq(0), 'imported customer keyed by mobile, OTP-only, credit 0');
|
||||
}
|
||||
|
||||
// ── 5 ──
|
||||
|
||||
@@ -90,7 +90,7 @@ const uniq = String(Date.now()).slice(-7);
|
||||
lastName: name.split(' ').slice(1).join(' '),
|
||||
mobile: `0917${uniq.slice(0, 3)}${suffix}`,
|
||||
nationalCode: `nc${uniq}${suffix}`,
|
||||
passwordHash: '', kind: 'APP_SHOPKEEPER', isActive: true,
|
||||
passwordHash: '!OTP_ONLY_NO_PASSWORD_LOGIN', kind: 'APP_SHOPKEEPER', isActive: true,
|
||||
goldCreditLimitGrams: '100',
|
||||
verificationStep: 'UNVERIFIED',
|
||||
},
|
||||
@@ -106,7 +106,7 @@ const uniq = String(Date.now()).slice(-7);
|
||||
data: {
|
||||
fullName: 'کارشناس مدارک', firstName: 'کارشناس', lastName: 'مدارک',
|
||||
mobile: `0918${uniq.slice(0, 3)}22`,
|
||||
passwordHash: '', role: 'SALES_EXPERT', isActive: true, wagePercent: '3',
|
||||
passwordHash: '!NO_PASSWORD_SET_YET', role: 'SALES_EXPERT', isActive: true, wagePercent: '3',
|
||||
},
|
||||
});
|
||||
await prisma.salesExpertProfile.create({
|
||||
|
||||
@@ -71,7 +71,7 @@ const uniq = String(Date.now()).slice(-7);
|
||||
lastName: name.split(' ').slice(1).join(' '),
|
||||
mobile: `0919${uniq.slice(0, 3)}${suffix}`,
|
||||
nationalCode: `nc${uniq}${suffix}`,
|
||||
passwordHash: '', kind: 'APP_SHOPKEEPER', isActive: true,
|
||||
passwordHash: '!OTP_ONLY_NO_PASSWORD_LOGIN', kind: 'APP_SHOPKEEPER', isActive: true,
|
||||
goldCreditLimitGrams: '500',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ const uniq = String(Date.now()).slice(-7);
|
||||
data: {
|
||||
fullName: `${first} ${last}`, firstName: first, lastName: last,
|
||||
mobile: `0915${uniq.slice(0, 3)}${suffix}`,
|
||||
passwordHash: '', role: 'SALES_EXPERT', isActive: true,
|
||||
passwordHash: '!NO_PASSWORD_SET_YET', role: 'SALES_EXPERT', isActive: true,
|
||||
wagePercent: '4.5',
|
||||
},
|
||||
});
|
||||
@@ -82,7 +82,7 @@ const uniq = String(Date.now()).slice(-7);
|
||||
fullName: name, firstName: name.split(' ')[0], lastName: name.split(' ').slice(1).join(' '),
|
||||
mobile: `0916${uniq.slice(0, 3)}${suffix}`,
|
||||
nationalCode: `nc${uniq}${suffix}`,
|
||||
passwordHash: '', kind: 'APP_SHOPKEEPER', isActive: true,
|
||||
passwordHash: '!OTP_ONLY_NO_PASSWORD_LOGIN', kind: 'APP_SHOPKEEPER', isActive: true,
|
||||
referredByAgentId: agentId, goldCreditLimitGrams: '100',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { AccountingOutbox, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
|
||||
import { ActiveProvider, AccountingConfigService } from './accounting-config.service';
|
||||
import { AccountingConfig } from './accounting-config';
|
||||
import { JalaliDate, parseJalali, toJalali } from './jalali';
|
||||
@@ -181,21 +182,17 @@ export class AccountingSyncService {
|
||||
* An account that exists only in the accounting software becomes an app
|
||||
* customer, keyed by mobile (owner: «اولویت با شماره هست نه کد ملی»). The
|
||||
* national code is the account's own when it is a real one and still free,
|
||||
* else the mobile — the fallback admin-customers and customer-auth already use.
|
||||
* else none (the column is optional since 2026-10-07 and holds national
|
||||
* codes only — never the mobile or a made-up key).
|
||||
* Credit limit 0 and UNVERIFIED: everything beyond identity is the app's.
|
||||
* OTP-only: an imported account has no app password.
|
||||
*/
|
||||
private async importCustomer(provider: ProviderName, ext: ExternalCustomer): Promise<string> {
|
||||
const mobile = ext.mobile as string;
|
||||
const candidates = [validNationalCode(ext.nationalCode), mobile, `${mobile}-${provider}-${ext.code}`].filter(
|
||||
(v): v is string => !!v,
|
||||
);
|
||||
let nationalCode = candidates[candidates.length - 1];
|
||||
for (const c of candidates) {
|
||||
const taken = await this.prisma.customer.findUnique({ where: { nationalCode: c }, select: { id: true } });
|
||||
if (!taken) {
|
||||
nationalCode = c;
|
||||
break;
|
||||
}
|
||||
let nationalCode = validNationalCode(ext.nationalCode);
|
||||
if (nationalCode) {
|
||||
const taken = await this.prisma.customer.findUnique({ where: { nationalCode }, select: { id: true } });
|
||||
if (taken) nationalCode = null;
|
||||
}
|
||||
const { firstName, lastName } = splitName(ext.name || mobile);
|
||||
|
||||
@@ -208,7 +205,7 @@ export class AccountingSyncService {
|
||||
lastName: lastName || null,
|
||||
mobile,
|
||||
nationalCode,
|
||||
passwordHash: '',
|
||||
passwordHash: OTP_ONLY_PASSWORD_HASH,
|
||||
phone: ext.phone,
|
||||
address: ext.address,
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AdminRole, Prisma } from '@prisma/client';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import { NO_PASSWORD_YET_HASH } from '../auth/password-hash';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
|
||||
/**
|
||||
@@ -327,7 +328,7 @@ export class AdminBranchesService {
|
||||
fullName,
|
||||
mobile: phone,
|
||||
// No password is set: the supervisor cannot sign in until one is issued.
|
||||
passwordHash: '',
|
||||
passwordHash: NO_PASSWORD_YET_HASH,
|
||||
role: AdminRole.WORKSHOP_SUPERVISOR,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -171,7 +171,13 @@ export class AdminCustomersController {
|
||||
@RequirePermission('persons.list.client.omde.edit')
|
||||
verify(
|
||||
@Body()
|
||||
body: { customer_id: string; sts_verify_step_one?: number; sts_verify_step_two?: number },
|
||||
body: {
|
||||
customer_id: string;
|
||||
sts_verify_step_one?: number;
|
||||
sts_verify_step_two?: number;
|
||||
/** Optional, only read on a reject. */
|
||||
reason?: string | null;
|
||||
},
|
||||
) {
|
||||
return this.customers.verify(body);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { VerificationStep } from '@prisma/client';
|
||||
import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
|
||||
import { AdminCustomersService } from './admin-customers.service';
|
||||
|
||||
/**
|
||||
* Owner rulings 2026-10-07:
|
||||
* - a reject stores REJECTED with an optional reason (it used to fall back to
|
||||
* UNVERIFIED / STEP_ONE_SUBMITTED and leave no trace);
|
||||
* - every value sits in its own column: no mobile in nationalCode, no ''
|
||||
* password — an absent national code is NULL, an absent password is the
|
||||
* explicit OTP-only marker.
|
||||
*/
|
||||
describe('AdminCustomersService', () => {
|
||||
function build(current: Record<string, unknown> = { id: 'c1', verificationStep: VerificationStep.STEP_ONE_SUBMITTED }) {
|
||||
const writes: Record<string, unknown>[] = [];
|
||||
const tx = {
|
||||
customer: {
|
||||
update: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
||||
writes.push(data);
|
||||
return { ...current, ...data };
|
||||
}),
|
||||
create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
||||
writes.push(data);
|
||||
return { id: 'new', ...data };
|
||||
}),
|
||||
findUnique: jest.fn(async () => current),
|
||||
},
|
||||
};
|
||||
const prisma = { ...tx, $transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)) };
|
||||
const accounting = { customerVerified: jest.fn(), customerChanged: jest.fn() };
|
||||
const sms = { send: jest.fn(async () => undefined) };
|
||||
const service = new AdminCustomersService(prisma as never, {} as never, sms as never, accounting as never);
|
||||
return { service, writes, accounting };
|
||||
}
|
||||
|
||||
describe('verify()', () => {
|
||||
it('a step-one reject stores REJECTED with the reason and time', async () => {
|
||||
const { service, writes } = build();
|
||||
await service.verify({ customer_id: 'c1', sts_verify_step_one: 3, reason: ' کارت ملی خوانا نیست ' });
|
||||
expect(writes[0]).toMatchObject({
|
||||
verificationStep: VerificationStep.REJECTED,
|
||||
verificationRejectionReason: 'کارت ملی خوانا نیست',
|
||||
});
|
||||
expect(writes[0].verificationRejectedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('a step-two reject is REJECTED too, and the reason is optional', async () => {
|
||||
const { service, writes } = build({ id: 'c1', verificationStep: VerificationStep.STEP_TWO_SUBMITTED });
|
||||
await service.verify({ customer_id: 'c1', sts_verify_step_two: 3 });
|
||||
expect(writes[0]).toMatchObject({ verificationStep: VerificationStep.REJECTED, verificationRejectionReason: null });
|
||||
});
|
||||
|
||||
it('accepting a rejected customer clears the reason', async () => {
|
||||
const { service, writes } = build({ id: 'c1', verificationStep: VerificationStep.REJECTED });
|
||||
await service.verify({ customer_id: 'c1', sts_verify_step_one: 2 });
|
||||
expect(writes[0]).toEqual({
|
||||
verificationStep: VerificationStep.STEP_TWO_SUBMITTED,
|
||||
verificationRejectionReason: null,
|
||||
verificationRejectedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('step-two accept verifies and tells accounting once', async () => {
|
||||
const { service, writes, accounting } = build({ id: 'c1', verificationStep: VerificationStep.STEP_TWO_SUBMITTED });
|
||||
await service.verify({ customer_id: 'c1', sts_verify_step_two: 2 });
|
||||
expect(writes[0].verificationStep).toBe(VerificationStep.VERIFIED);
|
||||
expect(accounting.customerVerified).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a reject never tells accounting', async () => {
|
||||
const { service, accounting } = build({ id: 'c1', verificationStep: VerificationStep.STEP_TWO_SUBMITTED });
|
||||
await service.verify({ customer_id: 'c1', sts_verify_step_two: 3 });
|
||||
expect(accounting.customerVerified).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('national code and password', () => {
|
||||
it('create() without a national code or password stores NULL and the OTP-only marker', async () => {
|
||||
const { service, writes } = build();
|
||||
await service.create({ fname: 'علی', lname: 'رضایی', mobile: '09120000000', percent: '3' });
|
||||
expect(writes[0].nationalCode).toBeNull();
|
||||
expect(writes[0].mobile).toBe('09120000000');
|
||||
expect(writes[0].passwordHash).toBe(OTP_ONLY_PASSWORD_HASH);
|
||||
});
|
||||
|
||||
it('create() keeps a typed national code, digits normalised', async () => {
|
||||
const { service, writes } = build();
|
||||
await service.create({ fname: 'علی', mobile: '09120000000', percent: '3', national_code: ' ۰۰۱۲۳۴۵۶۷۸ ' });
|
||||
expect(writes[0].nationalCode).toBe('0012345678');
|
||||
});
|
||||
|
||||
it('createSingle() never copies the mobile into nationalCode', async () => {
|
||||
const { service, writes } = build();
|
||||
await service.createSingle({ fname: 'علی', mobile: '09120000000' });
|
||||
expect(writes[0].nationalCode).toBeNull();
|
||||
expect(writes[0].passwordHash).toBe(OTP_ONLY_PASSWORD_HASH);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -12,6 +12,13 @@ import * as bcrypt from 'bcryptjs';
|
||||
*/
|
||||
export const OTP_ONLY_PASSWORD_HASH = '!OTP_ONLY_NO_PASSWORD_LOGIN';
|
||||
|
||||
/**
|
||||
* Same idea for an admin account created before anyone has issued it a
|
||||
* password (e.g. a workshop supervisor added from the branch form): it cannot
|
||||
* sign in until a password is set through the users screen.
|
||||
*/
|
||||
export const NO_PASSWORD_YET_HASH = '!NO_PASSWORD_SET_YET';
|
||||
|
||||
/**
|
||||
* A real bcrypt hash always starts with `$2` ($2a$, $2b$, or PHP's $2y$ from
|
||||
* the legacy server — all three verify with bcryptjs). Anything else is not a
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type IamIdentity,
|
||||
} from '../iam/iam.service';
|
||||
import { AppSessionService } from '../sessions/app-session.service';
|
||||
import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
|
||||
import { envOr, envOrNull } from '../../common/env';
|
||||
import { CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy';
|
||||
import { faToEn } from './digits';
|
||||
@@ -185,6 +186,13 @@ export class CustomerAuthService {
|
||||
* Returns null for any mobile that is not an active sales expert, so an
|
||||
* ordinary customer's login is completely unaffected.
|
||||
*/
|
||||
/** `code` when it is set and no customer holds it yet, else null. */
|
||||
private async freeNationalCode(code: string | null): Promise<string | null> {
|
||||
if (!code) return null;
|
||||
const taken = await this.prisma.customer.findUnique({ where: { nationalCode: code }, select: { id: true } });
|
||||
return taken ? null : code;
|
||||
}
|
||||
|
||||
private async resolveExpertTradingAccount(mobile: string): Promise<{ id: string } | null> {
|
||||
const admin = await this.prisma.adminUser.findFirst({
|
||||
where: { mobile, isActive: true, role: AdminRole.SALES_EXPERT },
|
||||
@@ -252,13 +260,13 @@ export class CustomerAuthService {
|
||||
firstName: admin.firstName,
|
||||
lastName: admin.lastName,
|
||||
mobile: admin.mobile,
|
||||
// Customer.nationalCode is required and unique. The expert's own code
|
||||
// when they have one, else the mobile — the same fallback
|
||||
// admin-customers.service.ts uses when a form omits it.
|
||||
nationalCode: admin.salesExpertProfile.nationalCode || admin.mobile,
|
||||
// The expert's own national code when they have one and no other
|
||||
// customer holds it (the column is unique); else none. Never the
|
||||
// mobile — the column holds national codes only.
|
||||
nationalCode: await this.freeNationalCode(admin.salesExpertProfile.nationalCode),
|
||||
// No app password: this account is reached only through the expert's
|
||||
// AdminUser session, never by a password login of its own.
|
||||
passwordHash: '',
|
||||
passwordHash: OTP_ONLY_PASSWORD_HASH,
|
||||
kind: CustomerKind.APP_SHOPKEEPER,
|
||||
goldCreditLimitGrams: admin.salesExpertProfile.gramLevel,
|
||||
makingChargeOffsetPercent: admin.wagePercent ?? new Prisma.Decimal(0),
|
||||
|
||||
@@ -150,7 +150,7 @@ export interface CustomerDetail {
|
||||
id: string;
|
||||
fullName: string;
|
||||
mobile: string;
|
||||
nationalCode: string;
|
||||
nationalCode: string | null;
|
||||
cityId: string | null;
|
||||
goldCreditLimitGrams: string;
|
||||
goldDebtGrams: string;
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</div>
|
||||
<div class="prof__spec">
|
||||
<dt>کد ملی</dt>
|
||||
<dd>{{ customer.nationalCode }}</dd>
|
||||
<dd>{{ customer.nationalCode || '-' }}</dd>
|
||||
</div>
|
||||
<div class="prof__spec">
|
||||
<dt>وضعیت احراز هویت</dt>
|
||||
|
||||
@@ -165,7 +165,7 @@ export interface CustomerDetail {
|
||||
id: string;
|
||||
fullName: string;
|
||||
mobile: string;
|
||||
nationalCode: string;
|
||||
nationalCode: string | null;
|
||||
cityId: string | null;
|
||||
goldCreditLimitGrams: string;
|
||||
goldDebtGrams: string;
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</div>
|
||||
<div class="prof__spec">
|
||||
<dt>کد ملی</dt>
|
||||
<dd>{{ customer.nationalCode }}</dd>
|
||||
<dd>{{ customer.nationalCode || '-' }}</dd>
|
||||
</div>
|
||||
<div class="prof__spec">
|
||||
<dt>وضعیت احراز هویت</dt>
|
||||
|
||||
Reference in New Issue
Block a user