feat(discount): admin Discount Management tab + uuid-based rule targets
Switch rule target references from int ids to uuids (frontend-friendly; the engine compares uuids directly) via a migration. Add a Discount Management tab to the subscription page with a full CRUD UI (DiscountTab): table + modal form with per-type dynamic target fields (tenant tag / service cascade / amount / visit count / specific patient / occasion + validity window), priority and combinable/active toggles. Verified TSC + build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { PlusIcon, PencilIcon, TrashIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { DiscountRule, DiscountRuleType } from '../types';
|
||||
import { formatRial, formatDate, tomanToRial, rialToToman, tehranWallClockToUnix } from '../lib/utils';
|
||||
import Modal from './ui/Modal';
|
||||
import ConfirmDialog from './ui/ConfirmDialog';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import PriceInput from './ui/PriceInput';
|
||||
import PersianDateInput from './ui/PersianDateInput';
|
||||
|
||||
const TYPE_LABELS: Record<DiscountRuleType, string> = {
|
||||
patient_tag: 'تگ بیمار',
|
||||
invoice_amount: 'مبلغ فاکتور',
|
||||
specific_patient: 'بیمار خاص',
|
||||
occasion: 'مناسبتی',
|
||||
service: 'سرویس',
|
||||
visit_count: 'تعداد مراجعات',
|
||||
};
|
||||
|
||||
interface Option { uuid: string; name?: string }
|
||||
|
||||
const unixToIso = (u: number | null): string => {
|
||||
if (!u) return '';
|
||||
const d = new Date(u * 1000);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
const isoToUnix = (iso: string): number | null => (iso ? tehranWallClockToUnix(iso, '00:00') : null);
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
type: DiscountRuleType;
|
||||
discount_type: 'percent' | 'fixed';
|
||||
value: number; // percent (0..100) or toman (fixed)
|
||||
priority: number;
|
||||
combinable: boolean;
|
||||
active: boolean;
|
||||
valid_from: string; // iso
|
||||
valid_to: string; // iso
|
||||
target_tag_uuid: string;
|
||||
target_record_uuid: string;
|
||||
target_service_item_uuid: string;
|
||||
min_amount_toman: number;
|
||||
min_visit_count: number;
|
||||
occasion_kind: '' | 'birthday';
|
||||
}
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
name: '', type: 'invoice_amount', discount_type: 'percent', value: 0, priority: 0,
|
||||
combinable: false, active: true, valid_from: '', valid_to: '',
|
||||
target_tag_uuid: '', target_record_uuid: '', target_service_item_uuid: '',
|
||||
min_amount_toman: 0, min_visit_count: 0, occasion_kind: '',
|
||||
});
|
||||
|
||||
const fromRule = (r: DiscountRule): FormState => ({
|
||||
name: r.name, type: r.type, discount_type: r.discount_type,
|
||||
value: r.discount_type === 'fixed' ? rialToToman(r.value) : r.value,
|
||||
priority: r.priority, combinable: r.combinable, active: r.active,
|
||||
valid_from: unixToIso(r.valid_from), valid_to: unixToIso(r.valid_to),
|
||||
target_tag_uuid: r.target_tag_uuid ?? '', target_record_uuid: r.target_record_uuid ?? '',
|
||||
target_service_item_uuid: r.target_service_item_uuid ?? '',
|
||||
min_amount_toman: r.min_amount_rials ? rialToToman(r.min_amount_rials) : 0,
|
||||
min_visit_count: r.min_visit_count ?? 0,
|
||||
occasion_kind: r.occasion_kind === 'birthday' ? 'birthday' : '',
|
||||
});
|
||||
|
||||
function labelStyle(): React.CSSProperties { return { fontSize: 12.5, color: 'var(--text-3)', display: 'block', marginBottom: 6 }; }
|
||||
|
||||
export default function DiscountTab() {
|
||||
const qc = useQueryClient();
|
||||
const [modal, setModal] = useState<'create' | DiscountRule | null>(null);
|
||||
const [toDelete, setToDelete] = useState<DiscountRule | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-discount-rules'],
|
||||
queryFn: () => api.get<ApiResponse<DiscountRule[]>>('/api/v1/admin/discount-rules'),
|
||||
});
|
||||
const rules: DiscountRule[] = (data?.data as any)?.data ?? (data?.data as any) ?? [];
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: (uuid: string) => api.delete(`/api/v1/admin/discount-rules/${uuid}`),
|
||||
onSuccess: () => { toast.success('قانون حذف شد'); setToDelete(null); qc.invalidateQueries({ queryKey: ['admin-discount-rules'] }); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const discountDisplay = (r: DiscountRule) =>
|
||||
r.discount_type === 'percent' ? `${r.value}٪` : formatRial(r.value);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>قوانین تخفیف عمومی — بر اساس تگ، مبلغ، بیمار، مناسبت، سرویس یا تعداد مراجعه</span>
|
||||
<button className="btn primary sm" onClick={() => setModal('create')}>
|
||||
<PlusIcon style={{ width: 15 }} /> قانون جدید
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
||||
) : rules.length === 0 ? (
|
||||
<div className="card" style={{ padding: 28, textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>هنوز قانونی تعریف نشده است.</div>
|
||||
) : (
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--surface-2)', textAlign: 'right' }}>
|
||||
<th style={{ padding: 10 }}>نام</th>
|
||||
<th style={{ padding: 10 }}>نوع</th>
|
||||
<th style={{ padding: 10 }}>تخفیف</th>
|
||||
<th style={{ padding: 10 }}>اولویت</th>
|
||||
<th style={{ padding: 10 }}>ترکیبپذیر</th>
|
||||
<th style={{ padding: 10 }}>وضعیت</th>
|
||||
<th style={{ padding: 10 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rules.map((r) => (
|
||||
<tr key={r.uuid} style={{ borderTop: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: 10 }}>{r.name}</td>
|
||||
<td style={{ padding: 10 }}>{TYPE_LABELS[r.type]}</td>
|
||||
<td style={{ padding: 10 }}>{discountDisplay(r)}</td>
|
||||
<td style={{ padding: 10 }}>{r.priority}</td>
|
||||
<td style={{ padding: 10 }}>{r.combinable ? 'بله' : 'خیر'}</td>
|
||||
<td style={{ padding: 10 }}>
|
||||
<span className={`badge ${r.active ? 'green' : ''}`}>{r.active ? 'فعال' : 'غیرفعال'}</span>
|
||||
</td>
|
||||
<td style={{ padding: 10, textAlign: 'left', whiteSpace: 'nowrap' }}>
|
||||
<button className="mini-btn" onClick={() => setModal(r)} aria-label="ویرایش"><PencilIcon style={{ width: 15 }} /></button>
|
||||
<button className="mini-btn" onClick={() => setToDelete(r)} aria-label="حذف"><TrashIcon style={{ width: 15 }} /></button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modal && (
|
||||
<RuleModal
|
||||
initial={modal === 'create' ? null : modal}
|
||||
onClose={() => setModal(null)}
|
||||
onSaved={() => { setModal(null); qc.invalidateQueries({ queryKey: ['admin-discount-rules'] }); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!toDelete}
|
||||
title="حذف قانون تخفیف"
|
||||
message={`آیا از حذف «${toDelete?.name}» مطمئن هستید؟`}
|
||||
confirmLabel="حذف"
|
||||
danger
|
||||
loading={removeMut.isPending}
|
||||
onConfirm={() => toDelete && removeMut.mutate(toDelete.uuid)}
|
||||
onCancel={() => setToDelete(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleModal({ initial, onClose, onSaved }: { initial: DiscountRule | null; onClose: () => void; onSaved: () => void }) {
|
||||
const [f, setF] = useState<FormState>(initial ? fromRule(initial) : emptyForm());
|
||||
const set = <K extends keyof FormState>(k: K, v: FormState[K]) => setF((s) => ({ ...s, [k]: v }));
|
||||
|
||||
const tagsQ = useQuery({
|
||||
queryKey: ['tenant-tags'], queryFn: () => api.get<ApiResponse<Option[]>>('/api/v1/tenant-tags'),
|
||||
enabled: f.type === 'patient_tag',
|
||||
});
|
||||
const sectionsQ = useQuery({
|
||||
queryKey: ['service-sections'], queryFn: () => api.get<ApiResponse<Option[]>>('/api/v1/service-sections'),
|
||||
enabled: f.type === 'service',
|
||||
});
|
||||
const [sectionUuid, setSectionUuid] = useState('');
|
||||
const itemsQ = useQuery({
|
||||
queryKey: ['service-items', sectionUuid], queryFn: () => api.get<ApiResponse<Option[]>>(`/api/v1/service-items/${sectionUuid}`),
|
||||
enabled: f.type === 'service' && !!sectionUuid,
|
||||
});
|
||||
const tags = (tagsQ.data?.data as any)?.data ?? (tagsQ.data?.data as any) ?? [];
|
||||
const sections = (sectionsQ.data?.data as any)?.data ?? (sectionsQ.data?.data as any) ?? [];
|
||||
const items = (itemsQ.data?.data as any)?.data ?? (itemsQ.data?.data as any) ?? [];
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const body: Record<string, unknown> = {
|
||||
name: f.name.trim(),
|
||||
type: f.type,
|
||||
discount_type: f.discount_type,
|
||||
value: f.discount_type === 'fixed' ? tomanToRial(f.value) : f.value,
|
||||
priority: f.priority,
|
||||
combinable: f.combinable,
|
||||
active: f.active,
|
||||
valid_from: isoToUnix(f.valid_from),
|
||||
valid_to: isoToUnix(f.valid_to),
|
||||
target_tag_uuid: f.type === 'patient_tag' ? (f.target_tag_uuid || null) : null,
|
||||
target_record_uuid: f.type === 'specific_patient' ? (f.target_record_uuid.trim() || null) : null,
|
||||
target_service_item_uuid: f.type === 'service' ? (f.target_service_item_uuid || null) : null,
|
||||
min_amount_rials: f.type === 'invoice_amount' ? tomanToRial(f.min_amount_toman) : null,
|
||||
min_visit_count: f.type === 'visit_count' ? f.min_visit_count : null,
|
||||
occasion_kind: f.type === 'occasion' ? (f.occasion_kind || null) : null,
|
||||
};
|
||||
return initial
|
||||
? api.patch(`/api/v1/admin/discount-rules/${initial.uuid}`, body)
|
||||
: api.post('/api/v1/admin/discount-rules', body);
|
||||
},
|
||||
onSuccess: () => { toast.success('قانون ذخیره شد'); onSaved(); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const canSave = f.name.trim().length > 0 && f.value >= 0;
|
||||
|
||||
return (
|
||||
<Modal open title={initial ? 'ویرایش قانون تخفیف' : 'قانون تخفیف جدید'} onClose={onClose}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<label style={labelStyle()}>نام قانون</label>
|
||||
<input className="cp-input" value={f.name} onChange={(e) => set('name', e.target.value)} placeholder="مثال: بیماران VIP" />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label style={labelStyle()}>نوع قانون</label>
|
||||
<SearchableSelect
|
||||
options={(Object.keys(TYPE_LABELS) as DiscountRuleType[]).map((t) => ({ value: t, label: TYPE_LABELS[t] }))}
|
||||
value={f.type} onChange={(v) => set('type', (v as DiscountRuleType) || 'invoice_amount')} height={38}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle()}>نوع تخفیف</label>
|
||||
<SearchableSelect
|
||||
options={[{ value: 'percent', label: 'درصدی' }, { value: 'fixed', label: 'مبلغ ثابت' }]}
|
||||
value={f.discount_type} onChange={(v) => set('discount_type', (v as 'percent' | 'fixed') || 'percent')} height={38}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label style={labelStyle()}>{f.discount_type === 'percent' ? 'درصد تخفیف (۰ تا ۱۰۰)' : 'مبلغ تخفیف (تومان)'}</label>
|
||||
{f.discount_type === 'percent'
|
||||
? <input className="cp-input" type="number" inputMode="numeric" min={0} max={100} value={f.value} onChange={(e) => set('value', Number(e.target.value) || 0)} />
|
||||
: <PriceInput value={f.value} onChange={(v) => set('value', v)} />}
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle()}>اولویت (بزرگتر = مهمتر)</label>
|
||||
<input className="cp-input" type="number" inputMode="numeric" min={0} value={f.priority} onChange={(e) => set('priority', Number(e.target.value) || 0)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* target فیلد پویا بر اساس نوع */}
|
||||
{f.type === 'patient_tag' && (
|
||||
<div>
|
||||
<label style={labelStyle()}>تگ بیمار</label>
|
||||
<SearchableSelect
|
||||
options={tags.map((t: Option) => ({ value: t.uuid, label: t.name ?? '' }))}
|
||||
value={f.target_tag_uuid || null} onChange={(v) => set('target_tag_uuid', v ? String(v) : '')}
|
||||
placeholder="انتخاب تگ" isLoading={tagsQ.isLoading} isClearable height={38}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{f.type === 'invoice_amount' && (
|
||||
<div>
|
||||
<label style={labelStyle()}>حداقل مبلغ فاکتور (تومان)</label>
|
||||
<PriceInput value={f.min_amount_toman} onChange={(v) => set('min_amount_toman', v)} />
|
||||
</div>
|
||||
)}
|
||||
{f.type === 'specific_patient' && (
|
||||
<div>
|
||||
<label style={labelStyle()}>شناسهی پروندهی بیمار (uuid)</label>
|
||||
<input className="cp-input" dir="ltr" value={f.target_record_uuid} onChange={(e) => set('target_record_uuid', e.target.value)} placeholder="record uuid" />
|
||||
</div>
|
||||
)}
|
||||
{f.type === 'service' && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label style={labelStyle()}>بخش</label>
|
||||
<SearchableSelect
|
||||
options={sections.map((s: Option) => ({ value: s.uuid, label: s.name ?? '' }))}
|
||||
value={sectionUuid || null} onChange={(v) => { setSectionUuid(v ? String(v) : ''); set('target_service_item_uuid', ''); }}
|
||||
placeholder="انتخاب بخش" isLoading={sectionsQ.isLoading} isClearable height={38}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle()}>سرویس</label>
|
||||
<SearchableSelect
|
||||
options={items.map((s: Option) => ({ value: s.uuid, label: s.name ?? '' }))}
|
||||
value={f.target_service_item_uuid || null} onChange={(v) => set('target_service_item_uuid', v ? String(v) : '')}
|
||||
placeholder="انتخاب سرویس" isDisabled={!sectionUuid} isLoading={itemsQ.isLoading} isClearable height={38}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{f.type === 'visit_count' && (
|
||||
<div>
|
||||
<label style={labelStyle()}>حداقل تعداد مراجعه</label>
|
||||
<input className="cp-input" type="number" inputMode="numeric" min={1} value={f.min_visit_count} onChange={(e) => set('min_visit_count', Number(e.target.value) || 0)} />
|
||||
</div>
|
||||
)}
|
||||
{f.type === 'occasion' && (
|
||||
<div>
|
||||
<label style={labelStyle()}>زیرنوع مناسبت</label>
|
||||
<SearchableSelect
|
||||
options={[{ value: '', label: 'بازهی زمانی' }, { value: 'birthday', label: 'تولد بیمار' }]}
|
||||
value={f.occasion_kind} onChange={(v) => set('occasion_kind', (v as '' | 'birthday'))} height={38}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* بازهی اعتبار (اختیاری؛ برای مناسبتی/موقت) */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label style={labelStyle()}>اعتبار از (اختیاری)</label>
|
||||
<PersianDateInput value={f.valid_from} onChange={(v) => set('valid_from', v)} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle()}>اعتبار تا (اختیاری)</label>
|
||||
<PersianDateInput value={f.valid_to} onChange={(v) => set('valid_to', v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 20, marginTop: 4 }}>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={f.combinable} onChange={(e) => set('combinable', e.target.checked)} /> قابل ترکیب با سایر تخفیفها
|
||||
</label>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={f.active} onChange={(e) => set('active', e.target.checked)} /> فعال
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}>
|
||||
<button className="btn ghost sm" onClick={onClose}>انصراف</button>
|
||||
<button className="btn primary sm" disabled={!canSave || save.isPending} onClick={() => save.mutate()}>
|
||||
{save.isPending ? 'در حال ذخیره...' : 'ذخیره'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import Modal from '../components/ui/Modal';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import DiscountTab from '../components/DiscountTab';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -435,19 +436,21 @@ function ReportTab() {
|
||||
// ── Main ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AdminSubscriptionPage() {
|
||||
const [tab, setTab] = useState<'plans' | 'report'>('plans');
|
||||
const [tab, setTab] = useState<'plans' | 'report' | 'discounts'>('plans');
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="مدیریت اشتراکها" description="تعریف پلنها، دورهها و گزارش فروش" />
|
||||
<PageHeader title="مدیریت اشتراکها" description="تعریف پلنها، دورهها، تخفیفها و گزارش فروش" />
|
||||
|
||||
<div className="seg" style={{ marginBottom: 20 }}>
|
||||
<button className={tab === 'plans' ? 'on' : ''} onClick={() => setTab('plans')}>پلنها و دورهها</button>
|
||||
<button className={tab === 'report' ? 'on' : ''} onClick={() => setTab('report')}>گزارش فروش</button>
|
||||
<button className={tab === 'plans' ? 'on' : ''} onClick={() => setTab('plans')}>پلنها و دورهها</button>
|
||||
<button className={tab === 'discounts' ? 'on' : ''} onClick={() => setTab('discounts')}>مدیریت تخفیفها</button>
|
||||
<button className={tab === 'report' ? 'on' : ''} onClick={() => setTab('report')}>گزارش فروش</button>
|
||||
</div>
|
||||
|
||||
{tab === 'plans' && <PlansTab />}
|
||||
{tab === 'report' && <ReportTab />}
|
||||
{tab === 'plans' && <PlansTab />}
|
||||
{tab === 'discounts' && <DiscountTab />}
|
||||
{tab === 'report' && <ReportTab />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,6 +124,46 @@ export interface AppointmentEvent {
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export type DiscountRuleType =
|
||||
| 'patient_tag'
|
||||
| 'invoice_amount'
|
||||
| 'specific_patient'
|
||||
| 'occasion'
|
||||
| 'service'
|
||||
| 'visit_count';
|
||||
|
||||
export interface DiscountRule {
|
||||
uuid: string;
|
||||
name: string;
|
||||
type: DiscountRuleType;
|
||||
discount_type: 'percent' | 'fixed';
|
||||
value: number;
|
||||
priority: number;
|
||||
combinable: boolean;
|
||||
active: boolean;
|
||||
valid_from: number | null;
|
||||
valid_to: number | null;
|
||||
target_tag_uuid: string | null;
|
||||
target_record_uuid: string | null;
|
||||
target_service_item_uuid: string | null;
|
||||
min_amount_rials: number | null;
|
||||
min_visit_count: number | null;
|
||||
occasion_kind: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface DiscountSuggestion {
|
||||
rule_uuid: string;
|
||||
rule_name: string;
|
||||
type: DiscountRuleType;
|
||||
discount_type: 'percent' | 'fixed';
|
||||
value: number;
|
||||
discount_rials: number;
|
||||
combinable: boolean;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export type PaymentStatus =
|
||||
| "pending"
|
||||
| "success"
|
||||
|
||||
@@ -7,11 +7,11 @@ Owner is resolved from the authenticated user (`ROLE_DOCTOR` → doctor, `ROLE_C
|
||||
|
||||
| type | target field(s) | meaning |
|
||||
|------|-----------------|---------|
|
||||
| `patient_tag` | `target_tag_id` (TenantTag id) | patient carries the tag |
|
||||
| `patient_tag` | `target_tag_uuid` (TenantTag uuid) | patient carries the tag |
|
||||
| `invoice_amount` | `min_amount_rials` | session `final_price_rials` ≥ threshold |
|
||||
| `specific_patient` | `target_record_id` (PatientRecord id) | a specific patient's record |
|
||||
| `specific_patient` | `target_record_uuid` (PatientRecord uuid) | a specific patient's record |
|
||||
| `occasion` | `valid_from`/`valid_to`, optional `occasion_kind: birthday` | date window; `birthday` also requires today == patient birthday (month/day) |
|
||||
| `service` | `target_service_item_id` (ServiceItem id) | session contains that service (discount base = that service's line total) |
|
||||
| `service` | `target_service_item_uuid` (ServiceItem uuid) | session contains that service (discount base = that service's line total) |
|
||||
| `visit_count` | `min_visit_count` | patient's session count ≥ threshold |
|
||||
|
||||
Shared fields: `discount_type` (`percent`|`fixed`), `value` (percent 0..100 or rials), `priority` (int, higher first), `combinable` (bool), `active` (bool), `valid_from`/`valid_to` (unix, nullable).
|
||||
@@ -40,7 +40,7 @@ Create a rule. **Auth:** doctor/clinic.
|
||||
| `combinable` | bool | ❌ | default false |
|
||||
| `active` | bool | ❌ | default true |
|
||||
| `valid_from` / `valid_to` | int (unix) | ❌ | validity window |
|
||||
| `target_tag_id` / `target_record_id` / `target_service_item_id` | int | ❌ | per-type target |
|
||||
| `target_tag_uuid` / `target_record_uuid` / `target_service_item_uuid` | string (uuid) | ❌ | per-type target |
|
||||
| `min_amount_rials` / `min_visit_count` | int | ❌ | per-type threshold |
|
||||
| `occasion_kind` | string | ❌ | `birthday` or null |
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260717082805 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Switch discount_rules target reference columns from int id to uuid';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE discount_rules ADD target_tag_uuid VARCHAR(36) DEFAULT NULL, ADD target_record_uuid VARCHAR(36) DEFAULT NULL, ADD target_service_item_uuid VARCHAR(36) DEFAULT NULL, DROP target_tag_id, DROP target_record_id, DROP target_service_item_id');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE discount_rules ADD target_tag_id INT DEFAULT NULL, ADD target_record_id INT DEFAULT NULL, ADD target_service_item_id INT DEFAULT NULL, DROP target_tag_uuid, DROP target_record_uuid, DROP target_service_item_uuid');
|
||||
}
|
||||
}
|
||||
@@ -161,9 +161,9 @@ class DiscountController extends BaseController
|
||||
if (array_key_exists('active', $data)) { $rule->setActive((bool) $data['active']); }
|
||||
if (array_key_exists('valid_from', $data)) { $rule->setValidFrom($data['valid_from'] !== null ? (int) $data['valid_from'] : null); }
|
||||
if (array_key_exists('valid_to', $data)) { $rule->setValidTo($data['valid_to'] !== null ? (int) $data['valid_to'] : null); }
|
||||
if (array_key_exists('target_tag_id', $data)) { $rule->setTargetTagId($data['target_tag_id'] !== null ? (int) $data['target_tag_id'] : null); }
|
||||
if (array_key_exists('target_record_id', $data)) { $rule->setTargetRecordId($data['target_record_id'] !== null ? (int) $data['target_record_id'] : null); }
|
||||
if (array_key_exists('target_service_item_id', $data)) { $rule->setTargetServiceItemId($data['target_service_item_id'] !== null ? (int) $data['target_service_item_id'] : null); }
|
||||
if (array_key_exists('target_tag_uuid', $data)) { $rule->setTargetTagUuid($data['target_tag_uuid'] !== null ? (string) $data['target_tag_uuid'] : null); }
|
||||
if (array_key_exists('target_record_uuid', $data)) { $rule->setTargetRecordUuid($data['target_record_uuid'] !== null ? (string) $data['target_record_uuid'] : null); }
|
||||
if (array_key_exists('target_service_item_uuid', $data)) { $rule->setTargetServiceItemUuid($data['target_service_item_uuid'] !== null ? (string) $data['target_service_item_uuid'] : null); }
|
||||
if (array_key_exists('min_amount_rials', $data)) { $rule->setMinAmountRials($data['min_amount_rials'] !== null ? (int) $data['min_amount_rials'] : null); }
|
||||
if (array_key_exists('min_visit_count', $data)) { $rule->setMinVisitCount($data['min_visit_count'] !== null ? (int) $data['min_visit_count'] : null); }
|
||||
if (array_key_exists('occasion_kind', $data)) { $rule->setOccasionKind($data['occasion_kind'] !== null ? (string) $data['occasion_kind'] : null); }
|
||||
|
||||
@@ -79,15 +79,15 @@ class DiscountRule
|
||||
#[ORM\Column(name: 'valid_to', type: 'integer', nullable: true)]
|
||||
private ?int $validTo = null;
|
||||
|
||||
// ── target fields (بسته به type فقط یکی معنیدار است) ──────────────────────
|
||||
#[ORM\Column(name: 'target_tag_id', type: 'integer', nullable: true)]
|
||||
private ?int $targetTagId = null;
|
||||
// ── target fields (بسته به type فقط یکی معنیدار است؛ uuid برای سازگاری با UI) ──
|
||||
#[ORM\Column(name: 'target_tag_uuid', type: 'string', length: 36, nullable: true)]
|
||||
private ?string $targetTagUuid = null;
|
||||
|
||||
#[ORM\Column(name: 'target_record_id', type: 'integer', nullable: true)]
|
||||
private ?int $targetRecordId = null;
|
||||
#[ORM\Column(name: 'target_record_uuid', type: 'string', length: 36, nullable: true)]
|
||||
private ?string $targetRecordUuid = null;
|
||||
|
||||
#[ORM\Column(name: 'target_service_item_id', type: 'integer', nullable: true)]
|
||||
private ?int $targetServiceItemId = null;
|
||||
#[ORM\Column(name: 'target_service_item_uuid', type: 'string', length: 36, nullable: true)]
|
||||
private ?string $targetServiceItemUuid = null;
|
||||
|
||||
#[ORM\Column(name: 'min_amount_rials', type: 'integer', nullable: true)]
|
||||
private ?int $minAmountRials = null;
|
||||
@@ -128,9 +128,9 @@ class DiscountRule
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function getValidFrom(): ?int { return $this->validFrom; }
|
||||
public function getValidTo(): ?int { return $this->validTo; }
|
||||
public function getTargetTagId(): ?int { return $this->targetTagId; }
|
||||
public function getTargetRecordId(): ?int { return $this->targetRecordId; }
|
||||
public function getTargetServiceItemId(): ?int { return $this->targetServiceItemId; }
|
||||
public function getTargetTagUuid(): ?string { return $this->targetTagUuid; }
|
||||
public function getTargetRecordUuid(): ?string { return $this->targetRecordUuid; }
|
||||
public function getTargetServiceItemUuid(): ?string { return $this->targetServiceItemUuid; }
|
||||
public function getMinAmountRials(): ?int { return $this->minAmountRials; }
|
||||
public function getMinVisitCount(): ?int { return $this->minVisitCount; }
|
||||
public function getOccasionKind(): ?string { return $this->occasionKind; }
|
||||
@@ -144,9 +144,9 @@ class DiscountRule
|
||||
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
|
||||
public function setValidFrom(?int $v): self { $this->validFrom = $v; $this->touch(); return $this; }
|
||||
public function setValidTo(?int $v): self { $this->validTo = $v; $this->touch(); return $this; }
|
||||
public function setTargetTagId(?int $v): self { $this->targetTagId = $v; $this->touch(); return $this; }
|
||||
public function setTargetRecordId(?int $v): self { $this->targetRecordId = $v; $this->touch(); return $this; }
|
||||
public function setTargetServiceItemId(?int $v): self { $this->targetServiceItemId = $v; $this->touch(); return $this; }
|
||||
public function setTargetTagUuid(?string $v): self { $this->targetTagUuid = $v; $this->touch(); return $this; }
|
||||
public function setTargetRecordUuid(?string $v): self { $this->targetRecordUuid = $v; $this->touch(); return $this; }
|
||||
public function setTargetServiceItemUuid(?string $v): self { $this->targetServiceItemUuid = $v; $this->touch(); return $this; }
|
||||
public function setMinAmountRials(?int $v): self { $this->minAmountRials = $v; $this->touch(); return $this; }
|
||||
public function setMinVisitCount(?int $v): self { $this->minVisitCount = $v; $this->touch(); return $this; }
|
||||
public function setOccasionKind(?string $v): self { $this->occasionKind = $v; $this->touch(); return $this; }
|
||||
@@ -166,10 +166,10 @@ class DiscountRule
|
||||
'active' => $this->active,
|
||||
'valid_from' => $this->validFrom,
|
||||
'valid_to' => $this->validTo,
|
||||
'target_tag_id' => $this->targetTagId,
|
||||
'target_record_id' => $this->targetRecordId,
|
||||
'target_service_item_id' => $this->targetServiceItemId,
|
||||
'min_amount_rials' => $this->minAmountRials,
|
||||
'target_tag_uuid' => $this->targetTagUuid,
|
||||
'target_record_uuid' => $this->targetRecordUuid,
|
||||
'target_service_item_uuid' => $this->targetServiceItemUuid,
|
||||
'min_amount_rials' => $this->minAmountRials,
|
||||
'min_visit_count' => $this->minVisitCount,
|
||||
'occasion_kind' => $this->occasionKind,
|
||||
'created_at' => $this->createdAt,
|
||||
|
||||
@@ -91,15 +91,15 @@ class DiscountEngine
|
||||
$record = $session->getRecord();
|
||||
|
||||
return match ($rule->getType()) {
|
||||
DiscountRule::TYPE_PATIENT_TAG => $this->hasTag($session, $rule->getTargetTagId()) ? $final : null,
|
||||
DiscountRule::TYPE_PATIENT_TAG => $this->hasTag($session, $rule->getTargetTagUuid()) ? $final : null,
|
||||
|
||||
DiscountRule::TYPE_INVOICE_AMOUNT => ($rule->getMinAmountRials() !== null && $final >= $rule->getMinAmountRials()) ? $final : null,
|
||||
|
||||
DiscountRule::TYPE_SPECIFIC_PATIENT => ($rule->getTargetRecordId() !== null && $record->getId() === $rule->getTargetRecordId()) ? $final : null,
|
||||
DiscountRule::TYPE_SPECIFIC_PATIENT => ($rule->getTargetRecordUuid() !== null && $record->getUuid() === $rule->getTargetRecordUuid()) ? $final : null,
|
||||
|
||||
DiscountRule::TYPE_OCCASION => $this->occasionMatches($rule, $session, $now) ? $final : null,
|
||||
|
||||
DiscountRule::TYPE_SERVICE => $this->serviceBase($session, $rule->getTargetServiceItemId()),
|
||||
DiscountRule::TYPE_SERVICE => $this->serviceBase($session, $rule->getTargetServiceItemUuid()),
|
||||
|
||||
DiscountRule::TYPE_VISIT_COUNT => ($rule->getMinVisitCount() !== null
|
||||
&& $this->sessionRepo->countByRecord($record) >= $rule->getMinVisitCount()) ? $final : null,
|
||||
@@ -108,13 +108,13 @@ class DiscountEngine
|
||||
};
|
||||
}
|
||||
|
||||
private function hasTag(PatientSession $session, ?int $tagId): bool
|
||||
private function hasTag(PatientSession $session, ?string $tagUuid): bool
|
||||
{
|
||||
if ($tagId === null) {
|
||||
if ($tagUuid === null) {
|
||||
return false;
|
||||
}
|
||||
foreach ($session->getRecord()->getTags() as $tag) {
|
||||
if ($tag->getId() === $tagId) {
|
||||
if ($tag->getUuid() === $tagUuid) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -122,15 +122,15 @@ class DiscountEngine
|
||||
}
|
||||
|
||||
/** مبنای تخفیف سرویس = جمع خطوطِ همان سرویس؛ null اگر سرویس در پرونده نباشد. */
|
||||
private function serviceBase(PatientSession $session, ?int $serviceItemId): ?int
|
||||
private function serviceBase(PatientSession $session, ?string $serviceItemUuid): ?int
|
||||
{
|
||||
if ($serviceItemId === null) {
|
||||
if ($serviceItemUuid === null) {
|
||||
return null;
|
||||
}
|
||||
$sum = 0;
|
||||
foreach ($session->getServices() as $line) {
|
||||
/** @var SessionService $line */
|
||||
if ($line->getServiceItem()->getId() === $serviceItemId) {
|
||||
if ($line->getServiceItem()->getUuid() === $serviceItemUuid) {
|
||||
$sum += $line->getLineTotalRials();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user