feat(tahesab): import and refresh customers at chosen hours
Each connection can set how many times a day and at which Tehran hours the server pulls Tahesab customers, including name updates for people already linked. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "AccountingConnection"
|
||||
ADD COLUMN "customerSyncHours" INTEGER[] NOT NULL DEFAULT ARRAY[]::INTEGER[],
|
||||
ADD COLUMN "customerSyncLastSlot" TEXT,
|
||||
ADD COLUMN "customerSyncLastRunAt" TIMESTAMP(3),
|
||||
ADD COLUMN "customerSyncLastSummary" TEXT,
|
||||
ADD COLUMN "customerSyncLastError" TEXT;
|
||||
@@ -1843,6 +1843,13 @@ model AccountingConnection {
|
||||
lastHealthAt DateTime?
|
||||
lastHealthOk Boolean?
|
||||
lastError String?
|
||||
/// ساعتهای بروزرسانی خودکار مشتریان (۰ تا ۲۳، وقت تهران). خالی یعنی خاموش.
|
||||
customerSyncHours Int[] @default([])
|
||||
/// نوبت claimشده، مثل 2026-11-05-08، تا در همان ساعت دو بار اجرا نشود
|
||||
customerSyncLastSlot String?
|
||||
customerSyncLastRunAt DateTime?
|
||||
customerSyncLastSummary String?
|
||||
customerSyncLastError String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
personLinks PersonAccountingLink[]
|
||||
|
||||
@@ -43,6 +43,10 @@ export class TahesabConnectionsService {
|
||||
lastHealthAt: Date | null;
|
||||
lastHealthOk: boolean | null;
|
||||
lastError: string | null;
|
||||
customerSyncHours: number[];
|
||||
customerSyncLastRunAt: Date | null;
|
||||
customerSyncLastSummary: string | null;
|
||||
customerSyncLastError: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
@@ -60,6 +64,10 @@ export class TahesabConnectionsService {
|
||||
lastHealthAt: row.lastHealthAt?.toISOString() ?? null,
|
||||
lastHealthOk: row.lastHealthOk,
|
||||
lastError: row.lastError,
|
||||
customerSyncHours: [...row.customerSyncHours].sort((a, b) => a - b),
|
||||
customerSyncLastRunAt: row.customerSyncLastRunAt?.toISOString() ?? null,
|
||||
customerSyncLastSummary: row.customerSyncLastSummary,
|
||||
customerSyncLastError: row.customerSyncLastError,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
@@ -201,6 +209,15 @@ export class TahesabConnectionsService {
|
||||
|
||||
if (dto.isDefault === true) await this.clearOtherDefaults(organizationId, id);
|
||||
|
||||
let customerSyncHours: number[] | undefined;
|
||||
if (dto.customerSyncHours !== undefined) {
|
||||
const hours = dto.customerSyncHours.map((hour) => Math.trunc(hour));
|
||||
if (new Set(hours).size !== hours.length) {
|
||||
throw new BadRequestException('ساعتهای بروزرسانی نباید تکراری باشند.');
|
||||
}
|
||||
customerSyncHours = [...hours].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
let enc = {
|
||||
ciphertext: row.tokenCiphertext,
|
||||
iv: row.tokenIv,
|
||||
@@ -225,6 +242,7 @@ export class TahesabConnectionsService {
|
||||
enabled: dto.enabled ?? row.enabled,
|
||||
isDefault: dto.isDefault === true ? true : dto.isDefault === false ? false : row.isDefault,
|
||||
allowInsecureTls: dto.allowInsecureTls ?? row.allowInsecureTls,
|
||||
...(customerSyncHours !== undefined ? { customerSyncHours } : {}),
|
||||
},
|
||||
});
|
||||
return this.toDto(updated);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TahesabPersonLinkService } from './person-link.service';
|
||||
|
||||
/** ساعت و تاریخ امروز به وقت تهران، برای نوبت یکبار در هر ساعت. */
|
||||
export function tehranSyncSlot(now = new Date()): { hour: number; slot: string } {
|
||||
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(now);
|
||||
const read = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
parts.find((part) => part.type === type)?.value ?? '';
|
||||
let hour = Number(read('hour'));
|
||||
if (!Number.isFinite(hour) || hour === 24) hour = 0;
|
||||
const slot = `${read('year')}-${read('month')}-${read('day')}-${String(hour).padStart(2, '0')}`;
|
||||
return { hour, slot };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TahesabCustomerSyncScheduler {
|
||||
private readonly logger = new Logger(TahesabCustomerSyncScheduler.name);
|
||||
private busy = false;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly links: TahesabPersonLinkService,
|
||||
) {}
|
||||
|
||||
@Cron('* * * * *', { timeZone: 'Asia/Tehran' })
|
||||
async tick() {
|
||||
if (process.env.NODE_ENV === 'test' || this.busy) return;
|
||||
this.busy = true;
|
||||
try {
|
||||
const { hour, slot } = tehranSyncSlot();
|
||||
const connections = await this.prisma.accountingConnection.findMany({
|
||||
where: {
|
||||
provider: 'tahesab',
|
||||
enabled: true,
|
||||
customerSyncHours: { has: hour },
|
||||
},
|
||||
select: { id: true, organizationId: true, label: true, customerSyncLastSlot: true },
|
||||
});
|
||||
for (const connection of connections) {
|
||||
if (connection.customerSyncLastSlot === slot) continue;
|
||||
const claimed = await this.prisma.accountingConnection.updateMany({
|
||||
where: {
|
||||
id: connection.id,
|
||||
OR: [
|
||||
{ customerSyncLastSlot: null },
|
||||
{ customerSyncLastSlot: { not: slot } },
|
||||
],
|
||||
},
|
||||
data: { customerSyncLastSlot: slot },
|
||||
});
|
||||
if (claimed.count !== 1) continue;
|
||||
await this.run(connection.organizationId, connection.id, connection.label);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`بررسی زمانبندی مشتریان تهحساب ناموفق بود: ${
|
||||
err instanceof Error ? err.message : err
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async run(organizationId: string, connectionId: string, label: string) {
|
||||
try {
|
||||
const result = await this.links.syncAllCustomers(organizationId, connectionId);
|
||||
await this.prisma.accountingConnection.update({
|
||||
where: { id: connectionId },
|
||||
data: {
|
||||
customerSyncLastRunAt: new Date(),
|
||||
customerSyncLastSummary: result.summary,
|
||||
customerSyncLastError: result.error,
|
||||
},
|
||||
});
|
||||
this.logger.log(`بروزرسانی خودکار «${label}»: ${result.summary}`);
|
||||
} catch (err) {
|
||||
const message = (err instanceof Error ? err.message : 'همگامسازی ناموفق بود').slice(0, 500);
|
||||
await this.prisma.accountingConnection.update({
|
||||
where: { id: connectionId },
|
||||
data: {
|
||||
customerSyncLastRunAt: new Date(),
|
||||
customerSyncLastSummary: null,
|
||||
customerSyncLastError: message,
|
||||
},
|
||||
});
|
||||
this.logger.warn(`بروزرسانی خودکار «${label}» ناموفق بود: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,16 @@ export class UpdateTahesabConnectionDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowInsecureTls?: boolean;
|
||||
|
||||
/** ساعتهای ۰ تا ۲۳. آرایهٔ خالی یعنی خاموش. */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(6)
|
||||
@Type(() => Number)
|
||||
@IsInt({ each: true })
|
||||
@Min(0, { each: true })
|
||||
@Max(23, { each: true })
|
||||
customerSyncHours?: number[];
|
||||
}
|
||||
|
||||
export class ManualLinkDto {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { carryPreviousSource } from '../../contacts/person-source';
|
||||
import { carryPreviousSource, namesDiffer } from '../../contacts/person-source';
|
||||
import {
|
||||
canonicalIranMobile,
|
||||
phoneIdentityKeys,
|
||||
@@ -558,6 +558,7 @@ export class TahesabPersonLinkService {
|
||||
organizationId: string,
|
||||
dto: ImportFromTahesabDto,
|
||||
prefetched?: { api: TahesabApi; row?: Record<string, unknown> },
|
||||
options?: { skipJob?: boolean },
|
||||
) {
|
||||
const clash = await this.prisma.personAccountingLink.findFirst({
|
||||
where: {
|
||||
@@ -711,18 +712,169 @@ export class TahesabPersonLinkService {
|
||||
});
|
||||
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
|
||||
|
||||
if (!options?.skipJob) {
|
||||
await this.prisma.accountingSyncJob.create({
|
||||
data: {
|
||||
organizationId,
|
||||
connectionId: dto.connectionId,
|
||||
kind: 'import',
|
||||
status: 'success',
|
||||
request: dto as unknown as Prisma.InputJsonValue,
|
||||
result: { personId, linkId: link.id } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { personId, link: this.linkDto(link) };
|
||||
}
|
||||
|
||||
/** نام، موبایل و گروه مشتریِ از قبل لینکشده را با ردیف تهحساب تازه میکند. */
|
||||
private async refreshLinkedCustomer(
|
||||
organizationId: string,
|
||||
connectionId: string,
|
||||
personId: string,
|
||||
moshtariCode: number,
|
||||
row: Record<string, unknown>,
|
||||
) {
|
||||
const existing = await this.getPerson(organizationId, personId);
|
||||
const picked = pickName(row).trim();
|
||||
const named = picked
|
||||
? applyPersonGenderTitle({ firstName: picked, lastName: '' })
|
||||
: null;
|
||||
const nameChanged =
|
||||
named != null &&
|
||||
namesDiffer(
|
||||
existing.firstName,
|
||||
existing.lastName,
|
||||
named.firstName,
|
||||
named.lastName,
|
||||
);
|
||||
await this.prisma.person.update({
|
||||
where: { id: personId },
|
||||
data: {
|
||||
...(named
|
||||
? {
|
||||
firstName: named.firstName,
|
||||
lastName: named.lastName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
}
|
||||
: {}),
|
||||
...(nameChanged
|
||||
? {
|
||||
previousFirstName: existing.firstName || null,
|
||||
previousLastName: existing.lastName || null,
|
||||
}
|
||||
: {}),
|
||||
...carryPreviousSource(existing, 'tahesab'),
|
||||
importSource: 'tahesab',
|
||||
sourceRef: String(moshtariCode),
|
||||
...(!existing.birthDate && pickBirthDate(row)
|
||||
? { birthDate: pickBirthDate(row) }
|
||||
: {}),
|
||||
...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing),
|
||||
},
|
||||
});
|
||||
await this.applyTahesabGroup(organizationId, connectionId, personId, row);
|
||||
}
|
||||
|
||||
/**
|
||||
* همهٔ مشتریان تهحساب را صفحه به صفحه میخواند.
|
||||
* کد تازه وارد میشود؛ کد لینکشده نام و شماره و گروهش تازه میشود.
|
||||
*/
|
||||
async syncAllCustomers(organizationId: string, connectionId: string) {
|
||||
const { api } = await this.connections.getApi(organizationId, connectionId);
|
||||
let page = 1;
|
||||
let emptyStreak = 0;
|
||||
let imported = 0;
|
||||
let updated = 0;
|
||||
let failed = 0;
|
||||
const errors: string[] = [];
|
||||
const maxPages = 300;
|
||||
|
||||
while (emptyStreak < 2 && page <= maxPages) {
|
||||
let listed: Awaited<ReturnType<typeof this.listMoshtariPage>>;
|
||||
try {
|
||||
listed = await this.listMoshtariPage(api, page, LIST_PAGE_SIZE);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'خواندن تهحساب ناموفق بود';
|
||||
errors.push(message);
|
||||
break;
|
||||
}
|
||||
if (!listed.rows.length) {
|
||||
emptyStreak += 1;
|
||||
page += 1;
|
||||
continue;
|
||||
}
|
||||
emptyStreak = 0;
|
||||
const rowsByCode = new Map<number, Record<string, unknown>>();
|
||||
for (const row of listed.rows) {
|
||||
const code = pickCode(row);
|
||||
if (code != null && code >= 1) rowsByCode.set(code, row);
|
||||
}
|
||||
const links = rowsByCode.size
|
||||
? await this.prisma.personAccountingLink.findMany({
|
||||
where: {
|
||||
connectionId,
|
||||
moshtariCode: { in: [...rowsByCode.keys()] },
|
||||
},
|
||||
select: { moshtariCode: true, personId: true },
|
||||
})
|
||||
: [];
|
||||
const linkedByCode = new Map(links.map((link) => [link.moshtariCode, link.personId]));
|
||||
for (const [code, row] of rowsByCode) {
|
||||
try {
|
||||
const personId = linkedByCode.get(code);
|
||||
if (personId) {
|
||||
await this.refreshLinkedCustomer(
|
||||
organizationId,
|
||||
connectionId,
|
||||
personId,
|
||||
code,
|
||||
row,
|
||||
);
|
||||
updated += 1;
|
||||
} else {
|
||||
await this.importFromTahesab(
|
||||
organizationId,
|
||||
{ connectionId, moshtariCode: code },
|
||||
{ api, row },
|
||||
{ skipJob: true },
|
||||
);
|
||||
imported += 1;
|
||||
}
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
if (errors.length < 5) {
|
||||
errors.push(err instanceof Error ? err.message : 'خطا');
|
||||
}
|
||||
}
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
|
||||
if (page > maxPages && emptyStreak < 2) {
|
||||
errors.push('فهرست مشتریان از سقف خواندن خودکار بیشتر بود.');
|
||||
}
|
||||
|
||||
const summary = `${imported} وارد شد، ${updated} بهروز شد، ${failed} خطا`;
|
||||
await this.prisma.accountingSyncJob.create({
|
||||
data: {
|
||||
organizationId,
|
||||
connectionId: dto.connectionId,
|
||||
kind: 'import',
|
||||
status: 'success',
|
||||
request: dto as unknown as Prisma.InputJsonValue,
|
||||
result: { personId, linkId: link.id } as Prisma.InputJsonValue,
|
||||
connectionId,
|
||||
kind: 'customer-sync',
|
||||
status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success',
|
||||
result: { imported, updated, failed, pages: page - 1 } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return { personId, link: this.linkDto(link) };
|
||||
this.logger.log(`همگامسازی مشتریان تهحساب ${connectionId}: ${summary}`);
|
||||
return {
|
||||
imported,
|
||||
updated,
|
||||
failed,
|
||||
summary,
|
||||
error: errors.length ? errors.join('؛ ') : null,
|
||||
};
|
||||
}
|
||||
|
||||
async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { TahesabBalanceService } from './balance.service';
|
||||
import { TahesabDocumentsService } from './documents.service';
|
||||
import { TahesabAsnadReportService } from './asnad-report.service';
|
||||
import { TahesabInvoiceSettingsService } from './invoice-settings.service';
|
||||
import { TahesabCustomerSyncScheduler } from './customer-sync.scheduler';
|
||||
import { TahesabController } from './tahesab.controller';
|
||||
import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module';
|
||||
|
||||
@@ -29,6 +30,7 @@ import { CustomerGroupsModule } from '../../customer-groups/customer-groups.modu
|
||||
TahesabDocumentsService,
|
||||
TahesabAsnadReportService,
|
||||
TahesabInvoiceSettingsService,
|
||||
TahesabCustomerSyncScheduler,
|
||||
],
|
||||
exports: [
|
||||
TahesabCryptoService,
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
UpdateTahesabConnectionRequest,
|
||||
} from '@mento/shared';
|
||||
import { brand } from '@mento/shared';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { formatJalaliDateTime, toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
UiBadge,
|
||||
UiBusyOverlay,
|
||||
@@ -74,6 +74,77 @@ const syncTablePageSize = ref(50);
|
||||
const syncTablePage = ref(1);
|
||||
|
||||
const syncOnlyNew = ref(false);
|
||||
const autoCount = ref('0');
|
||||
const autoHours = ref<string[]>([]);
|
||||
const autoSyncSaving = ref(false);
|
||||
const AUTO_HOUR_PRESETS: Record<number, number[]> = {
|
||||
1: [8],
|
||||
2: [8, 14],
|
||||
3: [8, 12, 16],
|
||||
4: [8, 12, 16, 20],
|
||||
5: [8, 11, 14, 17, 20],
|
||||
6: [8, 10, 12, 14, 16, 20],
|
||||
};
|
||||
|
||||
const selectedConnection = computed(() =>
|
||||
items.value.find((row) => row.id === sync.connectionId),
|
||||
);
|
||||
|
||||
function hourLabel(hour: number) {
|
||||
return `${toFaDigits(String(hour).padStart(2, '0'))}:۰۰`;
|
||||
}
|
||||
|
||||
function fillAutoSync() {
|
||||
const hours = [...(selectedConnection.value?.customerSyncHours ?? [])]
|
||||
.sort((a, b) => a - b)
|
||||
.slice(0, 6);
|
||||
autoCount.value = String(hours.length);
|
||||
autoHours.value = hours.map(String);
|
||||
}
|
||||
|
||||
function onAutoCount(value: string) {
|
||||
const count = Math.min(6, Math.max(0, Number(value) || 0));
|
||||
const preset = AUTO_HOUR_PRESETS[count] ?? [];
|
||||
const next = autoHours.value.slice(0, count);
|
||||
while (next.length < count) {
|
||||
next.push(String(preset[next.length] ?? 8));
|
||||
}
|
||||
autoCount.value = String(count);
|
||||
autoHours.value = next;
|
||||
}
|
||||
|
||||
function setAutoHour(index: number, value: string) {
|
||||
autoHours.value[index] = value;
|
||||
}
|
||||
|
||||
async function saveAutoSync() {
|
||||
if (!sync.connectionId) return;
|
||||
const hours = autoHours.value.map((hour) => Number(hour));
|
||||
if (hours.some((hour) => !Number.isInteger(hour) || hour < 0 || hour > 23)) {
|
||||
error.value = 'ساعت باید بین ۰ و ۲۳ باشد.';
|
||||
return;
|
||||
}
|
||||
if (new Set(hours).size !== hours.length) {
|
||||
error.value = 'ساعتها نباید تکراری باشند.';
|
||||
return;
|
||||
}
|
||||
autoSyncSaving.value = true;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
await api.patch(`/integrations/tahesab/connections/${sync.connectionId}`, {
|
||||
customerSyncHours: hours,
|
||||
});
|
||||
ok.value = hours.length
|
||||
? 'زمانبندی بروزرسانی ذخیره شد.'
|
||||
: 'بروزرسانی خودکار خاموش شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiErr(e, 'ذخیره زمانبندی ناموفق بود');
|
||||
} finally {
|
||||
autoSyncSaving.value = false;
|
||||
}
|
||||
}
|
||||
const syncVisibleResults = computed(() =>
|
||||
syncOnlyNew.value
|
||||
? syncResults.value.filter((r) => !r.alreadyLinked)
|
||||
@@ -86,6 +157,7 @@ const syncPagedResults = computed(() => {
|
||||
const start = (syncTablePage.value - 1) * syncTablePageSize.value;
|
||||
return syncVisibleResults.value.slice(start, start + syncTablePageSize.value);
|
||||
});
|
||||
watch(() => sync.connectionId, () => fillAutoSync());
|
||||
watch([syncOnlyNew, syncTablePageSize], () => {
|
||||
syncTablePage.value = 1;
|
||||
});
|
||||
@@ -178,6 +250,7 @@ async function load() {
|
||||
if (!editingId.value) form.isDefault = data.length === 0;
|
||||
if (!sync.connectionId && data[0]) sync.connectionId = data[0].id;
|
||||
if (!productSync.connectionId && data[0]) productSync.connectionId = data[0].id;
|
||||
fillAutoSync();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String(
|
||||
@@ -875,6 +948,63 @@ onMounted(load);
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
|
||||
<div class="sync-sub-section">
|
||||
<h4 class="sync-subhead">بروزرسانی خودکار</h4>
|
||||
<p class="sync-sub-desc">
|
||||
تعداد دفعات در روز و ساعت هر بار را برای همین اتصال مشخص کنید. سرور در آن ساعتها، به وقت تهران،
|
||||
مشتریان تازه را وارد میکند و نام مشتریهای قبلی را هم با تهحساب تازه میکند. اگر شماره یکی باشد
|
||||
همان مشتری میماند، نام قبلی حفظ میشود و شماره به شکل ۰۹۱۲ ذخیره میشود. خاموش یعنی فقط ورود دستی.
|
||||
</p>
|
||||
<UiField label="تعداد در روز">
|
||||
<UiSelect
|
||||
:model-value="autoCount"
|
||||
:disabled="!sync.connectionId || autoSyncSaving"
|
||||
@update:model-value="onAutoCount"
|
||||
>
|
||||
<option value="0">خاموش</option>
|
||||
<option v-for="n in 6" :key="n" :value="String(n)">
|
||||
{{ toFaDigits(n) }} بار
|
||||
</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
<div v-if="Number(autoCount) > 0" class="crm-form-grid-3">
|
||||
<UiField
|
||||
v-for="(_, index) in autoHours"
|
||||
:key="index"
|
||||
:label="`ساعت ${toFaDigits(index + 1)}`"
|
||||
>
|
||||
<UiSelect
|
||||
:model-value="autoHours[index]"
|
||||
:disabled="autoSyncSaving"
|
||||
@update:model-value="setAutoHour(index, $event)"
|
||||
>
|
||||
<option v-for="hour in 24" :key="hour - 1" :value="String(hour - 1)">
|
||||
{{ hourLabel(hour - 1) }}
|
||||
</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
</div>
|
||||
<div class="sync-buttons">
|
||||
<UiButton
|
||||
:loading="autoSyncSaving"
|
||||
:disabled="!sync.connectionId"
|
||||
@click="saveAutoSync"
|
||||
>
|
||||
ذخیره زمانبندی
|
||||
</UiButton>
|
||||
</div>
|
||||
<p v-if="selectedConnection?.customerSyncLastRunAt" class="sync-sub-desc">
|
||||
آخرین اجرا:
|
||||
{{ formatJalaliDateTime(new Date(selectedConnection.customerSyncLastRunAt)) }}
|
||||
<template v-if="selectedConnection.customerSyncLastSummary">
|
||||
— {{ toFaDigits(selectedConnection.customerSyncLastSummary) }}
|
||||
</template>
|
||||
</p>
|
||||
<p v-if="selectedConnection?.customerSyncLastError" class="sync-sub-desc">
|
||||
آخرین خطا: {{ selectedConnection.customerSyncLastError }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sync-sub-section">
|
||||
<h4 class="sync-subhead">۱) وارد کردن دستهای با بازه کد مشتری</h4>
|
||||
<p class="sync-sub-desc">
|
||||
|
||||
@@ -4287,6 +4287,11 @@ export interface TahesabConnectionDto {
|
||||
lastHealthAt: string | null;
|
||||
lastHealthOk: boolean | null;
|
||||
lastError: string | null;
|
||||
/** ساعتهای ۰ تا ۲۳ به وقت تهران. خالی یعنی بروزرسانی خودکار خاموش است. */
|
||||
customerSyncHours: number[];
|
||||
customerSyncLastRunAt: string | null;
|
||||
customerSyncLastSummary: string | null;
|
||||
customerSyncLastError: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -4301,7 +4306,11 @@ export interface CreateTahesabConnectionRequest {
|
||||
allowInsecureTls?: boolean;
|
||||
}
|
||||
|
||||
export type UpdateTahesabConnectionRequest = Partial<CreateTahesabConnectionRequest>;
|
||||
export interface UpdateTahesabConnectionRequest
|
||||
extends Partial<CreateTahesabConnectionRequest> {
|
||||
/** آرایهٔ خالی بروزرسانی خودکار را خاموش میکند. */
|
||||
customerSyncHours?: number[];
|
||||
}
|
||||
|
||||
export interface PersonAccountingLinkDto {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user