migration
This commit is contained in:
@@ -127,7 +127,12 @@
|
|||||||
class="cursor-pointer w-5 ml-1"
|
class="cursor-pointer w-5 ml-1"
|
||||||
:class="stepIconClass(getStepOneIcon(record))"
|
: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>
|
</div>
|
||||||
</a-popconfirm>
|
</a-popconfirm>
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
@@ -213,6 +218,26 @@
|
|||||||
</template>
|
</template>
|
||||||
</XTable>
|
</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
|
<BulkCustomer
|
||||||
v-if="showAddButton"
|
v-if="showAddButton"
|
||||||
:key="editingId ?? 'new'"
|
:key="editingId ?? 'new'"
|
||||||
@@ -365,12 +390,38 @@ async function postVerify(payload: Record<string, unknown>, errorMsg: string) {
|
|||||||
|
|
||||||
const acceptStepOne = (id: string) =>
|
const acceptStepOne = (id: string) =>
|
||||||
postVerify({ customer_id: id, sts_verify_step_one: 2 }, 'خطا در تغییر وضعیت اولیه مشتری');
|
postVerify({ customer_id: id, sts_verify_step_one: 2 }, 'خطا در تغییر وضعیت اولیه مشتری');
|
||||||
const rejectStepOne = (id: string) =>
|
const rejectStepOne = (id: string) => openReject(id, 'sts_verify_step_one');
|
||||||
postVerify({ customer_id: id, sts_verify_step_one: 3 }, 'خطا در تغییر وضعیت اولیه مشتری');
|
|
||||||
const acceptStepTwo = (id: string) =>
|
const acceptStepTwo = (id: string) =>
|
||||||
postVerify({ customer_id: id, sts_verify_step_two: 2 }, 'خطا در تغییر وضعیت سفارش مشتری');
|
postVerify({ customer_id: id, sts_verify_step_two: 2 }, 'خطا در تغییر وضعیت سفارش مشتری');
|
||||||
const rejectStepTwo = (id: string) =>
|
const rejectStepTwo = (id: string) => openReject(id, 'sts_verify_step_two');
|
||||||
postVerify({ customer_id: id, sts_verify_step_two: 3 }, 'خطا در تغییر وضعیت سفارش مشتری');
|
|
||||||
|
/**
|
||||||
|
* 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 */
|
/** legacy inline ternary on @confirm, preserved including the NonVerified quirk */
|
||||||
function onConfirmStepOne(record: Record<string, any>) {
|
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({
|
const customer = await prisma.customer.upsert({
|
||||||
where: { mobile },
|
where: { mobile },
|
||||||
update: {},
|
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 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 });
|
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' } });
|
const imported = await prisma.accountingLink.findFirst({ where: { origin: 'IMPORTED' } });
|
||||||
if (imported) {
|
if (imported) {
|
||||||
const c = await prisma.customer.findUnique({ where: { id: imported.entityId } });
|
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 ──
|
// ── 5 ──
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ const uniq = String(Date.now()).slice(-7);
|
|||||||
lastName: name.split(' ').slice(1).join(' '),
|
lastName: name.split(' ').slice(1).join(' '),
|
||||||
mobile: `0917${uniq.slice(0, 3)}${suffix}`,
|
mobile: `0917${uniq.slice(0, 3)}${suffix}`,
|
||||||
nationalCode: `nc${uniq}${suffix}`,
|
nationalCode: `nc${uniq}${suffix}`,
|
||||||
passwordHash: '', kind: 'APP_SHOPKEEPER', isActive: true,
|
passwordHash: '!OTP_ONLY_NO_PASSWORD_LOGIN', kind: 'APP_SHOPKEEPER', isActive: true,
|
||||||
goldCreditLimitGrams: '100',
|
goldCreditLimitGrams: '100',
|
||||||
verificationStep: 'UNVERIFIED',
|
verificationStep: 'UNVERIFIED',
|
||||||
},
|
},
|
||||||
@@ -106,7 +106,7 @@ const uniq = String(Date.now()).slice(-7);
|
|||||||
data: {
|
data: {
|
||||||
fullName: 'کارشناس مدارک', firstName: 'کارشناس', lastName: 'مدارک',
|
fullName: 'کارشناس مدارک', firstName: 'کارشناس', lastName: 'مدارک',
|
||||||
mobile: `0918${uniq.slice(0, 3)}22`,
|
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({
|
await prisma.salesExpertProfile.create({
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const uniq = String(Date.now()).slice(-7);
|
|||||||
lastName: name.split(' ').slice(1).join(' '),
|
lastName: name.split(' ').slice(1).join(' '),
|
||||||
mobile: `0919${uniq.slice(0, 3)}${suffix}`,
|
mobile: `0919${uniq.slice(0, 3)}${suffix}`,
|
||||||
nationalCode: `nc${uniq}${suffix}`,
|
nationalCode: `nc${uniq}${suffix}`,
|
||||||
passwordHash: '', kind: 'APP_SHOPKEEPER', isActive: true,
|
passwordHash: '!OTP_ONLY_NO_PASSWORD_LOGIN', kind: 'APP_SHOPKEEPER', isActive: true,
|
||||||
goldCreditLimitGrams: '500',
|
goldCreditLimitGrams: '500',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const uniq = String(Date.now()).slice(-7);
|
|||||||
data: {
|
data: {
|
||||||
fullName: `${first} ${last}`, firstName: first, lastName: last,
|
fullName: `${first} ${last}`, firstName: first, lastName: last,
|
||||||
mobile: `0915${uniq.slice(0, 3)}${suffix}`,
|
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',
|
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(' '),
|
fullName: name, firstName: name.split(' ')[0], lastName: name.split(' ').slice(1).join(' '),
|
||||||
mobile: `0916${uniq.slice(0, 3)}${suffix}`,
|
mobile: `0916${uniq.slice(0, 3)}${suffix}`,
|
||||||
nationalCode: `nc${uniq}${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',
|
referredByAgentId: agentId, goldCreditLimitGrams: '100',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { AccountingOutbox, Prisma } from '@prisma/client';
|
import { AccountingOutbox, Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
|
||||||
import { ActiveProvider, AccountingConfigService } from './accounting-config.service';
|
import { ActiveProvider, AccountingConfigService } from './accounting-config.service';
|
||||||
import { AccountingConfig } from './accounting-config';
|
import { AccountingConfig } from './accounting-config';
|
||||||
import { JalaliDate, parseJalali, toJalali } from './jalali';
|
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
|
* An account that exists only in the accounting software becomes an app
|
||||||
* customer, keyed by mobile (owner: «اولویت با شماره هست نه کد ملی»). The
|
* customer, keyed by mobile (owner: «اولویت با شماره هست نه کد ملی»). The
|
||||||
* national code is the account's own when it is a real one and still free,
|
* 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.
|
* 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> {
|
private async importCustomer(provider: ProviderName, ext: ExternalCustomer): Promise<string> {
|
||||||
const mobile = ext.mobile as string;
|
const mobile = ext.mobile as string;
|
||||||
const candidates = [validNationalCode(ext.nationalCode), mobile, `${mobile}-${provider}-${ext.code}`].filter(
|
let nationalCode = validNationalCode(ext.nationalCode);
|
||||||
(v): v is string => !!v,
|
if (nationalCode) {
|
||||||
);
|
const taken = await this.prisma.customer.findUnique({ where: { nationalCode }, select: { id: true } });
|
||||||
let nationalCode = candidates[candidates.length - 1];
|
if (taken) nationalCode = null;
|
||||||
for (const c of candidates) {
|
|
||||||
const taken = await this.prisma.customer.findUnique({ where: { nationalCode: c }, select: { id: true } });
|
|
||||||
if (!taken) {
|
|
||||||
nationalCode = c;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const { firstName, lastName } = splitName(ext.name || mobile);
|
const { firstName, lastName } = splitName(ext.name || mobile);
|
||||||
|
|
||||||
@@ -208,7 +205,7 @@ export class AccountingSyncService {
|
|||||||
lastName: lastName || null,
|
lastName: lastName || null,
|
||||||
mobile,
|
mobile,
|
||||||
nationalCode,
|
nationalCode,
|
||||||
passwordHash: '',
|
passwordHash: OTP_ONLY_PASSWORD_HASH,
|
||||||
phone: ext.phone,
|
phone: ext.phone,
|
||||||
address: ext.address,
|
address: ext.address,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { AdminRole, Prisma } from '@prisma/client';
|
|||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { TableService } from '../admin-table/table.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';
|
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -327,7 +328,7 @@ export class AdminBranchesService {
|
|||||||
fullName,
|
fullName,
|
||||||
mobile: phone,
|
mobile: phone,
|
||||||
// No password is set: the supervisor cannot sign in until one is issued.
|
// No password is set: the supervisor cannot sign in until one is issued.
|
||||||
passwordHash: '',
|
passwordHash: NO_PASSWORD_YET_HASH,
|
||||||
role: AdminRole.WORKSHOP_SUPERVISOR,
|
role: AdminRole.WORKSHOP_SUPERVISOR,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -171,7 +171,13 @@ export class AdminCustomersController {
|
|||||||
@RequirePermission('persons.list.client.omde.edit')
|
@RequirePermission('persons.list.client.omde.edit')
|
||||||
verify(
|
verify(
|
||||||
@Body()
|
@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);
|
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 { TableService } from '../admin-table/table.service';
|
||||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||||
import { faToEn } from '../customer-auth/digits';
|
import { faToEn } from '../customer-auth/digits';
|
||||||
|
import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
|
||||||
import { SmsService } from '../sms/sms.service';
|
import { SmsService } from '../sms/sms.service';
|
||||||
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.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.
|
* `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.
|
* 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 {
|
function verifyStepOne(step: VerificationStep): number {
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case VerificationStep.UNVERIFIED:
|
case VerificationStep.UNVERIFIED:
|
||||||
@@ -314,6 +325,8 @@ export class AdminCustomersService {
|
|||||||
sts_verify_step_one: verifyStepOne(c.verificationStep),
|
sts_verify_step_one: verifyStepOne(c.verificationStep),
|
||||||
sts_verify_step_two: verifyStepTwo(c.verificationStep),
|
sts_verify_step_two: verifyStepTwo(c.verificationStep),
|
||||||
verificationStep: c.verificationStep,
|
verificationStep: c.verificationStep,
|
||||||
|
verification_rejection_reason: c.verificationRejectionReason,
|
||||||
|
verification_rejected_at: c.verificationRejectedAt,
|
||||||
national_id_image: c.nationalIdImageUrl,
|
national_id_image: c.nationalIdImageUrl,
|
||||||
business_license: c.businessLicenseUrl,
|
business_license: c.businessLicenseUrl,
|
||||||
shop_image: c.shopImageUrl,
|
shop_image: c.shopImageUrl,
|
||||||
@@ -631,8 +644,9 @@ export class AdminCustomersService {
|
|||||||
firstName: firstName || null,
|
firstName: firstName || null,
|
||||||
lastName: lastName || null,
|
lastName: lastName || null,
|
||||||
mobile,
|
mobile,
|
||||||
nationalCode: mobile, // schema requires it and it is unique; singleCustomer.vue collects no ncode
|
// singleCustomer.vue collects no national code.
|
||||||
passwordHash: '',
|
nationalCode: null,
|
||||||
|
passwordHash: OTP_ONLY_PASSWORD_HASH,
|
||||||
cityId: (data.city_id as string) || null,
|
cityId: (data.city_id as string) || null,
|
||||||
kind: CustomerKind.RETAIL,
|
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
|
// can never log in: the app's own faToEn turns the typed number into ASCII
|
||||||
// and the lookup misses.
|
// and the lookup misses.
|
||||||
const mobile = faToEn(String(data.mobile ?? '').trim());
|
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) {
|
if (!fullName || !mobile) {
|
||||||
throw new BadRequestException('نام و موبایل الزامی است');
|
throw new BadRequestException('نام و موبایل الزامی است');
|
||||||
@@ -691,12 +705,12 @@ export class AdminCustomersService {
|
|||||||
// (AdminController.php:338) and is applied client-side.
|
// (AdminController.php:338) and is applied client-side.
|
||||||
makingChargeOffsetPercent: new Prisma.Decimal(String(data.percent)),
|
makingChargeOffsetPercent: new Prisma.Decimal(String(data.percent)),
|
||||||
mobile,
|
mobile,
|
||||||
nationalCode: nationalCode || mobile, // schema requires it and it is unique
|
nationalCode,
|
||||||
// The form collects a password (bulkCustomer.vue / ReagentCustomer.vue).
|
// The form collects a password (bulkCustomer.vue / ReagentCustomer.vue).
|
||||||
// It used to be discarded and '' stored, so the operator typed a password
|
// It used to be discarded and '' stored, so the operator typed a password
|
||||||
// that silently did nothing. Hashed here when supplied; '' still means
|
// that silently did nothing. Hashed here when supplied; otherwise the
|
||||||
// "no password yet", which no bcrypt hash can ever match.
|
// account signs in by OTP only.
|
||||||
passwordHash: data.password ? await bcrypt.hash(String(data.password), 10) : '',
|
passwordHash: data.password ? await bcrypt.hash(String(data.password), 10) : OTP_ONLY_PASSWORD_HASH,
|
||||||
cityId: (data.city_id as string) ?? null,
|
cityId: (data.city_id as string) ?? null,
|
||||||
goldCreditLimitGrams: data.gram_level
|
goldCreditLimitGrams: data.gram_level
|
||||||
? new Prisma.Decimal(String(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
|
// that lock NOT ported, so the field stays editable here and is normalised
|
||||||
// instead. Logged in ISSUES as PERS-01.
|
// instead. Logged in ISSUES as PERS-01.
|
||||||
if (data.mobile !== undefined) patch.mobile = faToEn(String(data.mobile));
|
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) {
|
if (data.city_id !== undefined) {
|
||||||
patch.city = data.city_id ? { connect: { id: String(data.city_id) } } : { disconnect: true };
|
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
|
* Legacy's payload is `{ customer_id, sts_verify_step_one }` or
|
||||||
* `{ customer_id, sts_verify_step_two }`, where the value is 2 (accept) or
|
* `{ customer_id, sts_verify_step_two }`, where the value is 2 (accept) or
|
||||||
* 3 (reject) — see OmdeList.vue acceptStepOne/rejectStepOne. Accepted here in
|
* 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: {
|
async verify(payload: {
|
||||||
customer_id: string;
|
customer_id: string;
|
||||||
sts_verify_step_one?: number;
|
sts_verify_step_one?: number;
|
||||||
sts_verify_step_two?: number;
|
sts_verify_step_two?: number;
|
||||||
|
reason?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const id = payload.customer_id;
|
const id = payload.customer_id;
|
||||||
const c = await this.prisma.customer.findUnique({ where: { id } });
|
const c = await this.prisma.customer.findUnique({ where: { id } });
|
||||||
if (!c) throw new NotFoundException('مشتری یافت نشد');
|
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) {
|
let data: Prisma.CustomerUpdateInput;
|
||||||
next =
|
if (step === 2) {
|
||||||
payload.sts_verify_step_one === 2
|
data = {
|
||||||
? VerificationStep.STEP_TWO_SUBMITTED // step one accepted -> step two pending
|
verificationStep:
|
||||||
: VerificationStep.UNVERIFIED; // rejected
|
payload.sts_verify_step_one !== undefined
|
||||||
} else if (payload.sts_verify_step_two !== undefined) {
|
? VerificationStep.STEP_TWO_SUBMITTED // step one accepted -> step two pending
|
||||||
next =
|
: VerificationStep.VERIFIED,
|
||||||
payload.sts_verify_step_two === 2
|
verificationRejectionReason: null,
|
||||||
? VerificationStep.VERIFIED
|
verificationRejectedAt: null,
|
||||||
: VerificationStep.STEP_ONE_SUBMITTED; // rejected -> back to step one accepted
|
};
|
||||||
} else {
|
} 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) => {
|
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
|
// D27 — a verified customer gets an account in the accounting software
|
||||||
// (queued; searched by mobile first, so an existing account is linked).
|
// (queued; searched by mobile first, so an existing account is linked).
|
||||||
if (next === VerificationStep.VERIFIED && c.verificationStep !== VerificationStep.VERIFIED) {
|
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';
|
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
|
* 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
|
* the legacy server — all three verify with bcryptjs). Anything else is not a
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type IamIdentity,
|
type IamIdentity,
|
||||||
} from '../iam/iam.service';
|
} from '../iam/iam.service';
|
||||||
import { AppSessionService } from '../sessions/app-session.service';
|
import { AppSessionService } from '../sessions/app-session.service';
|
||||||
|
import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
|
||||||
import { envOr, envOrNull } from '../../common/env';
|
import { envOr, envOrNull } from '../../common/env';
|
||||||
import { CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy';
|
import { CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy';
|
||||||
import { faToEn } from './digits';
|
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
|
* Returns null for any mobile that is not an active sales expert, so an
|
||||||
* ordinary customer's login is completely unaffected.
|
* 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> {
|
private async resolveExpertTradingAccount(mobile: string): Promise<{ id: string } | null> {
|
||||||
const admin = await this.prisma.adminUser.findFirst({
|
const admin = await this.prisma.adminUser.findFirst({
|
||||||
where: { mobile, isActive: true, role: AdminRole.SALES_EXPERT },
|
where: { mobile, isActive: true, role: AdminRole.SALES_EXPERT },
|
||||||
@@ -252,13 +260,13 @@ export class CustomerAuthService {
|
|||||||
firstName: admin.firstName,
|
firstName: admin.firstName,
|
||||||
lastName: admin.lastName,
|
lastName: admin.lastName,
|
||||||
mobile: admin.mobile,
|
mobile: admin.mobile,
|
||||||
// Customer.nationalCode is required and unique. The expert's own code
|
// The expert's own national code when they have one and no other
|
||||||
// when they have one, else the mobile — the same fallback
|
// customer holds it (the column is unique); else none. Never the
|
||||||
// admin-customers.service.ts uses when a form omits it.
|
// mobile — the column holds national codes only.
|
||||||
nationalCode: admin.salesExpertProfile.nationalCode || admin.mobile,
|
nationalCode: await this.freeNationalCode(admin.salesExpertProfile.nationalCode),
|
||||||
// No app password: this account is reached only through the expert's
|
// No app password: this account is reached only through the expert's
|
||||||
// AdminUser session, never by a password login of its own.
|
// AdminUser session, never by a password login of its own.
|
||||||
passwordHash: '',
|
passwordHash: OTP_ONLY_PASSWORD_HASH,
|
||||||
kind: CustomerKind.APP_SHOPKEEPER,
|
kind: CustomerKind.APP_SHOPKEEPER,
|
||||||
goldCreditLimitGrams: admin.salesExpertProfile.gramLevel,
|
goldCreditLimitGrams: admin.salesExpertProfile.gramLevel,
|
||||||
makingChargeOffsetPercent: admin.wagePercent ?? new Prisma.Decimal(0),
|
makingChargeOffsetPercent: admin.wagePercent ?? new Prisma.Decimal(0),
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export interface CustomerDetail {
|
|||||||
id: string;
|
id: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
mobile: string;
|
mobile: string;
|
||||||
nationalCode: string;
|
nationalCode: string | null;
|
||||||
cityId: string | null;
|
cityId: string | null;
|
||||||
goldCreditLimitGrams: string;
|
goldCreditLimitGrams: string;
|
||||||
goldDebtGrams: string;
|
goldDebtGrams: string;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="prof__spec">
|
<div class="prof__spec">
|
||||||
<dt>کد ملی</dt>
|
<dt>کد ملی</dt>
|
||||||
<dd>{{ customer.nationalCode }}</dd>
|
<dd>{{ customer.nationalCode || '-' }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="prof__spec">
|
<div class="prof__spec">
|
||||||
<dt>وضعیت احراز هویت</dt>
|
<dt>وضعیت احراز هویت</dt>
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export interface CustomerDetail {
|
|||||||
id: string;
|
id: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
mobile: string;
|
mobile: string;
|
||||||
nationalCode: string;
|
nationalCode: string | null;
|
||||||
cityId: string | null;
|
cityId: string | null;
|
||||||
goldCreditLimitGrams: string;
|
goldCreditLimitGrams: string;
|
||||||
goldDebtGrams: string;
|
goldDebtGrams: string;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="prof__spec">
|
<div class="prof__spec">
|
||||||
<dt>کد ملی</dt>
|
<dt>کد ملی</dt>
|
||||||
<dd>{{ customer.nationalCode }}</dd>
|
<dd>{{ customer.nationalCode || '-' }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="prof__spec">
|
<div class="prof__spec">
|
||||||
<dt>وضعیت احراز هویت</dt>
|
<dt>وضعیت احراز هویت</dt>
|
||||||
|
|||||||
Reference in New Issue
Block a user