feat: customer groups for people and companies with tahesab group sync and bulk register

Multiple groups per contact, legacy group text migrated, excel import/export, contacts filter and column, richer create-in-tahesab, match-first bulk export
This commit is contained in:
2026-10-21 17:16:31 +03:30
parent ce27df20ca
commit 29d99cbded
27 changed files with 1728 additions and 26 deletions
+167
View File
@@ -496,6 +496,128 @@ async function importBulk() {
}
}
/** همهٔ بازه‌های کد خوانده می‌شود و گروه ته‌حساب روی مشتری‌های لینک‌شده نشانده می‌شود */
async function syncGroups() {
error.value = '';
ok.value = '';
if (!sync.connectionId) {
error.value = 'ابتدا اتصال را انتخاب کنید.';
return;
}
syncBusy.value = true;
importOverlay.open = true;
importOverlay.progress = null;
const totals = { rows: 0, linked: 0, updated: 0 };
try {
let page = 1;
let emptyStreak = 0;
while (emptyStreak < LIST_ALL_EMPTY_STOP) {
importOverlay.message = `در حال همگام‌سازی گروه‌ها… ${toFaDigits(totals.rows)} مشتری بررسی شد · ${toFaDigits(totals.updated)} تغییر`;
const { data } = await api.post<{ rows: number; linked: number; updated: number }>(
'/integrations/tahesab/sync/groups',
{ connectionId: sync.connectionId, page, pageSize: LIST_ALL_CODE_PAGE },
{ timeout: 120_000 },
);
totals.rows += data.rows;
totals.linked += data.linked;
totals.updated += data.updated;
emptyStreak = data.rows ? 0 : emptyStreak + 1;
page += 1;
}
ok.value = `گروه‌ها همگام شد: ${toFaDigits(totals.linked)} مشتری لینک‌شده بررسی شد · گروه ${toFaDigits(totals.updated)} نفر به‌روز شد.`;
} catch (e) {
error.value = apiErr(e, 'همگام‌سازی گروه‌ها ناموفق بود');
} finally {
importOverlay.open = false;
syncBusy.value = false;
}
}
type ExportItem = {
personId: string;
name: string;
status: 'matched' | 'created' | 'ambiguous' | 'skipped' | 'error';
moshtariCode?: number;
error?: string;
};
const EXPORT_CHUNK = 10;
const exportIssues = ref<ExportItem[]>([]);
/** شخص‌های لینک‌نشده: اول تطبیق، بعد ساخت در ته‌حساب (بدون ساخت تکراری) */
async function exportUnlinked() {
error.value = '';
ok.value = '';
exportIssues.value = [];
if (!sync.connectionId) {
error.value = 'ابتدا اتصال را انتخاب کنید.';
return;
}
syncBusy.value = true;
try {
const { data: unlinked } = await api.get<{ ids: string[]; total: number }>(
'/integrations/tahesab/sync/unlinked-people',
{ params: { connectionId: sync.connectionId } },
);
if (!unlinked.total) {
ok.value = 'همهٔ اشخاص به ته‌حساب لینک هستند.';
return;
}
if (
!confirm(
`${toFaDigits(unlinked.total)} شخص به ته‌حساب لینک نیستند.\n` +
'برای هر نفر اول با موبایل/نام در ته‌حساب جستجو می‌شود: اگر دقیقاً یک نفر پیدا شد فقط لینک می‌شود، ' +
'اگر چند نفر پیدا شد رد می‌شود تا دستی بررسی کنید، و اگر پیدا نشد در ته‌حساب ساخته می‌شود. ادامه؟',
)
) {
return;
}
importOverlay.open = true;
importOverlay.progress = 0;
const totals = { matched: 0, created: 0, ambiguous: 0, skipped: 0, failed: 0 };
const ids = unlinked.ids;
for (let i = 0; i < ids.length; i += EXPORT_CHUNK) {
const chunk = ids.slice(i, i + EXPORT_CHUNK);
importOverlay.message = `در حال ثبت در ته‌حساب… ${toFaDigits(Math.min(i + chunk.length, ids.length))} از ${toFaDigits(ids.length)}`;
try {
const { data } = await api.post<typeof totals & { items: ExportItem[] }>(
'/integrations/tahesab/sync/export-people',
{ connectionId: sync.connectionId, personIds: chunk },
{ timeout: 180_000 },
);
totals.matched += data.matched;
totals.created += data.created;
totals.ambiguous += data.ambiguous;
totals.skipped += data.skipped;
totals.failed += data.failed;
exportIssues.value.push(
...data.items.filter((it) => it.status === 'ambiguous' || it.status === 'error'),
);
} catch (e) {
totals.failed += chunk.length;
exportIssues.value.push(
...chunk.map((personId) => ({
personId,
name: '',
status: 'error' as const,
error: apiErr(e, 'خطا در ارسال'),
})),
);
}
importOverlay.progress = ((i + chunk.length) / ids.length) * 100;
}
ok.value =
`ثبت در ته‌حساب تمام شد: لینک با مشتری موجود ${toFaDigits(totals.matched)} · ` +
`ساخته‌شده ${toFaDigits(totals.created)} · چند نتیجه (نیاز به بررسی) ${toFaDigits(totals.ambiguous)} · ` +
`ناموفق ${toFaDigits(totals.failed)}`;
} catch (e) {
error.value = apiErr(e, 'ثبت گروهی در ته‌حساب ناموفق بود');
} finally {
importOverlay.open = false;
importOverlay.progress = null;
syncBusy.value = false;
}
}
async function searchProducts() {
error.value = '';
ok.value = '';
@@ -807,6 +929,43 @@ onMounted(load);
</div>
</div>
<div class="sync-sub-section">
<h4 class="sync-subhead">۳) گروه‌ها و ثبت مشتری‌های منتکت در ته‌حساب</h4>
<p class="sync-sub-desc">
«همگام‌سازی گروه‌ها» گروه هر مشتری لینک‌شده را از ته‌حساب می‌خواند؛ فقط گروهِ آمده از ته‌حساب عوض
می‌شود و گروه‌هایی که دستی داده‌اید می‌مانند.
«ثبت لینک‌نشده‌ها» برای هر شخصی که به ته‌حساب لینک نیست اول تطبیق (موبایل، بعد نام) را امتحان
می‌کند تا مشتری تکراری ساخته نشود؛ اگر پیدا نشد با گروه، آدرس، کد ملی و تاریخ تولد ساخته می‌شود.
</p>
<div class="sync-buttons">
<UiButton
variant="secondary"
:disabled="!sync.connectionId || syncBusy"
@click="syncGroups"
>
همگام‌سازی گروه‌ها از ته‌حساب
</UiButton>
<UiButton :disabled="!sync.connectionId || syncBusy" @click="exportUnlinked">
ثبت مشتری‌های لینک‌نشده در ته‌حساب
</UiButton>
</div>
<div v-if="exportIssues.length" class="export-issues">
<p class="sync-sub-desc">
این اشخاص ثبت نشدند ({{ toFaDigits(exportIssues.length) }}). از پرونده، بخش «ته‌حساب و دارایی»
دستی لینک یا ثبت کنید:
</p>
<ul>
<li v-for="it in exportIssues" :key="it.personId">
<RouterLink :to="{ name: 'person-detail', params: { id: it.personId } }">
{{ it.name || 'بدون نام' }}
</RouterLink>
—
{{ it.status === 'ambiguous' ? 'چند مشتری مشابه در ته‌حساب پیدا شد' : it.error || 'خطا' }}
</li>
</ul>
</div>
</div>
<div v-if="syncResults.length" class="sync-results-box">
<h4 class="sync-results-title">
نتایج جستجو ({{ syncVisibleResults.length }}<template v-if="syncOnlyNew"> از {{ syncResults.length }}</template>)
@@ -1176,6 +1335,14 @@ onMounted(load);
flex-wrap: wrap;
}
.export-issues ul {
margin: var(--sp-2) 0 0;
padding-inline-start: 1.2rem;
font-size: var(--fs-xs);
max-height: 14rem;
overflow-y: auto;
}
.sync-table-pagination {
margin-top: var(--sp-3);
}