# مشخصات فنی پیاده‌سازی عمده‌فروشی و Import لیست قیمت PDF
## پروژه: تبریز سوغات

این فایل برای تحویل به یک هوش مصنوعی/توسعه‌دهنده تهیه شده و باید به‌عنوان **Specification اجرایی** برای اصلاح سیستم Laravel استفاده شود.

---

## 1) هدف اصلی

سیستم باید بتواند:

1. محصولات عمده‌فروشی را از محصولات عادی/خرده‌فروشی تفکیک کند.
2. برای محصول عمده دو نوع فروش داشته باشد:
   - `bulk` = فله
   - `packaged` = بسته‌ای
3. برای حداقل سفارش، دو فیلد مستقل داشته باشد:
   - حداقل سفارش فله بر اساس **کیلوگرم**
   - حداقل سفارش بسته‌ای بر اساس **تعداد**
4. برای هر محصول یک **وزن پایه (Base Weight)** داشته باشد که فقط بر اساس وزن سنجیده می‌شود:
   - `kg`
   - `g`
5. وزن‌ها در سمت PHP/API به‌صورت `float` در دسترس باشند، ولی در دیتابیس برای جلوگیری از خطای اعشاری از `DECIMAL` استفاده شود.
6. کاربر بتواند یک PDF لیست قیمت آپلود کند.
7. سیستم PDF را حتی اگر اسکن‌شده و بدون متن قابل استخراج باشد پردازش کند.
8. نام محصولات داخل PDF را با محصولات موجود در دیتابیس تطبیق دهد.
9. اختلاف‌های نوشتاری فارسی/عربی، فاصله، اعداد فارسی، `کیلو` / `کیلوگرم` / `کیلو گرم` / `kg` و `گرم` / `g` / `gr` و موارد مشابه را بفهمد.
10. قبل از اعمال قیمت‌ها، نتیجه‌ی Match را نمایش دهد و موارد مبهم را برای بررسی دستی نگه دارد.
11. پس از تأیید، قیمت محصول را آپدیت کند و تمام تغییرات را لاگ کند.
12. بعد از یک Match دستی موفق، نام PDF را به‌عنوان Alias آن محصول ذخیره کند تا importهای بعدی دقیق‌تر شوند.

---

# 2) وضعیت فعلی دیتابیس و نکات مهم

## 2.1) جدول `products`

در ساختار فعلی `products` این فیلدها وجود دارند:

- `price`
- `discount_price`
- `quantity_in_stock`
- `weight`
- `minimum_weight`
- `dimensions`

مشکل فعلی این است که معنی `weight`، `minimum_weight` و `dimensions` ثابت و قابل اتکاء نیست.

نمونه واقعی از dump:

- محصول `250` با نام `لوکوم فیتیل مغزدار 14 طعم فله پرمغز` مقدار `weight=1.00`، `minimum_weight=4.00` و `dimensions='kg'` دارد.
- محصول `252` با نام `لوکوم فیتیل مغزدار کریستالی 600 گرمی پرمغز` مقدار `weight=1.00`، `minimum_weight=600.00` و `dimensions='g'` دارد.

پس این سه فیلد نباید مبنای جدید منطق عمده‌فروشی باشند.

### تصمیم

فیلدهای جدید و شفاف ساخته شوند و منطق عمده‌فروشی از آن‌ها استفاده کند.

`weight` فقط در صورتی نگه داشته شود که جای دیگری از سیستم (مثلاً ارسال/محاسبه وزن واقعی کالا) از آن استفاده می‌کند. نباید صرفاً برای منطق عمده‌فروشی دوباره معناگذاری شود.

`minimum_weight` و `dimensions` به‌عنوان **Legacy** در نظر گرفته شوند و بعد از انتقال کامل منطق و اطمینان از نبودن Reference در کد، قابل حذف باشند.

---

## 2.2) جدول `product_categories`

در دیتابیس فعلی دسته‌ی:

```text
id = 9
name = عمده
slug = wholesale
```

وجود دارد.

بنابراین برای Match پیش‌فرض محصولات لیست عمده، به‌جای Hard Code کردن `category_id = 9`، باید از:

```php
ProductCategory::where('slug', 'wholesale')->value('id')
```

یا معادل Repository/Service آن استفاده شود.

### نکته مهم

در همین دیتابیس فیلدهای زیر روی `product_categories` وجود دارند:

- `wholesale_rule_enabled`
- `wholesale_min_distinct_products`

این فیلدها به منطق موجود عمده‌فروشی مربوط هستند، ولی **نباید با نوع فروش محصول (`bulk`/`packaged`) قاطی شوند**.

---

# 3) مدل داده جدید برای هر محصول

برای `products` این فیلدها اضافه شوند:

| Field | Type | توضیح |
|---|---|---|
| `wholesale_sale_mode` | enum/string | `bulk` یا `packaged` |
| `wholesale_min_order_kg` | decimal(12,3) nullable | حداقل سفارش برای فله |
| `wholesale_min_order_count` | unsigned integer nullable | حداقل سفارش برای بسته‌ای |
| `wholesale_base_weight` | decimal(12,3) nullable | وزن پایه |
| `wholesale_base_weight_unit` | enum/string nullable | فقط `kg` یا `g` |

## قرارداد منطقی

### حالت `bulk`

مثال:

```text
wholesale_sale_mode = bulk
wholesale_min_order_kg = 4.0
wholesale_min_order_count = null
wholesale_base_weight = 1.0
wholesale_base_weight_unit = kg
```

معنی:

- محصول فله است.
- حداقل خرید = ۴ کیلو.
- هر قیمت پایه = بر اساس ۱ کیلو.
- مقدار پایه‌ی ۱ کیلو ثابت است مگر اینکه Business Rule بعداً تغییر کند.

### حالت `packaged`

مثال:

```text
wholesale_sale_mode = packaged
wholesale_min_order_kg = null
wholesale_min_order_count = 10
wholesale_base_weight = 400.0
wholesale_base_weight_unit = g
```

معنی:

- محصول بسته‌ای است.
- حداقل سفارش = ۱۰ عدد بسته.
- وزن هر بسته = ۴۰۰ گرم.
- وزن پایه برای محاسبات وزنی ۴۰۰ گرم است.

### قانون مهم

هیچ‌وقت:

```text
packaged => minimum_order_kg
bulk => minimum_order_count
```

به‌صورت همزمان استفاده نشود.

فقط فیلد متناظر با `wholesale_sale_mode` مقدار داشته باشد.

---

# 4) چرا Base Weight باید جدا از Minimum Order باشد؟

این دو مفهوم متفاوت هستند.

## مثال فله

فرض کنید PDF بگوید:

```text
لوکوم فله ... | بسته ۴ کیلویی
```

در این حالت:

```text
minimum_order_kg = 4
base_weight = 1 kg
```

عدد `4` مربوط به Minimum Order است و عدد `1 kg` مربوط به Base Weight.

نباید Base Weight را ۴kg ذخیره کرد.

## مثال بسته‌ای

اگر PDF/اطلاعات محصول بگوید:

```text
لوکوم ... 400 گرم
```

و حداقل سفارش مثلاً ۱۰ بسته باشد:

```text
minimum_order_count = 10
base_weight = 400 g
```

---

# 5) نکته مهم درباره Float

کاربر سیستم می‌خواهد وزن‌ها اعشاری باشند، مثل:

```text
1
1.5
2.25
3.5
0.4
```

اما در دیتابیس از `FLOAT` استفاده نشود، چون برای داده‌های تجاری و مقایسه‌های دقیق می‌تواند خطای اعشاری ایجاد کند.

### پیشنهاد

Database:

```sql
DECIMAL(12,3)
```

Laravel Model casts:

```php
protected $casts = [
    'wholesale_min_order_kg' => 'float',
    'wholesale_base_weight' => 'float',
];
```

یعنی:

- در DB = Decimal
- در PHP/API = Float

---

# 6) اصلاح ساختار Import لیست قیمت

در دیتابیس فعلی سیستم از قبل این جدول‌ها وجود دارند:

- `price_list_imports`
- `price_list_items`
- `product_update_logs`

پس **جدول جدید برای کل Import ساخته نشود**؛ همین ساختار ارتقا داده شود.

---

# 7) تغییرات جدول `price_list_imports`

فیلدهای فعلی:

```text
id
file_name
status
total_items
created_by
created_at
updated_at
```

فیلدهای جدید پیشنهادی:

```text
category_scope_id nullable
selected_price_source nullable
applied_at nullable
matched_items nullable
updated_items nullable
needs_review_items nullable
unmatched_items nullable
```

### `selected_price_source`

مقدارهای:

```text
cash
45_day
```

چون PDF فعلی دو ستون قیمت دارد:

- `نقدی (پیش واریز)`
- `۴۵ روزه`

سیستم نباید این دو قیمت را در مرحله OCR از بین ببرد.

**انتخاب اینکه کدام قیمت وارد `products.price` شود باید Configurable باشد.**

پیشنهاد پیش‌فرض پروژه:

```text
selected_price_source = cash
```

ولی در UI Import امکان تغییر آن وجود داشته باشد.

---

# 8) تغییرات جدول `price_list_items`

فیلدهای فعلی:

```text
original_name
normalized_name
price
matched_product_id
match_score
match_status
matched_name
reviewed_at
reviewed_by
candidates_json
```

ساختار جدید باید اطلاعات استخراج‌شده از PDF را نیز نگه دارد.

### فیلدهای جدید

```text
cash_price
days45_price

extracted_sale_mode

extracted_min_order_kg
extracted_min_order_count

extracted_base_weight
extracted_base_weight_unit

source_description

price_source

match_method

parser_confidence

raw_data
```

### توضیح

`price` فعلی حفظ شود تا Compatibility با کد فعلی از بین نرود.

`price` = قیمت انتخاب‌شده برای Apply.

مثلاً اگر:

```text
price_source = cash
```

آنگاه:

```text
price = cash_price
```

و اگر:

```text
price_source = 45_day
```

آنگاه:

```text
price = days45_price
```

---

# 9) نوع `candidates_json`

در سیستم فعلی `candidates_json` از نوع `LONGTEXT` با `JSON_VALID` است و نمونه‌های فعلی نشان می‌دهند که داده‌ها گاهی به شکل JSON دوباره Serialize شده‌اند.

این الگو اصلاح شود.

پیشنهاد:

```php
$table->json('candidates_json')->nullable()->change();
```

و در PHP همیشه:

```php
json_encode($data, JSON_UNESCAPED_UNICODE)
```

فقط یک بار انجام شود.

از JSON String داخل JSON پرهیز شود.

---

# 10) جدول جدید `product_name_aliases`

برای اینکه Match در Importهای بعدی بهتر شود، یک جدول جدید ساخته شود:

```text
product_name_aliases
```

### ستون‌ها

```text
id
product_id
alias
normalized_alias
source
is_active
created_at
updated_at
```

### نمونه

```text
product_id = 250
alias = "لوكوم فيتيل مغزدار 14 طعم فله پرمغز"
normalized_alias = "لوکوم فیتیل مغزدار 14 طعم فله پرمغز"
source = "import_confirmed"
```

بعد از اولین Match دستی موفق:

```text
PDF name -> Product
```

یک Alias ساخته شود.

در Import بعدی:

```text
normalized_alias exact match
```

قبل از Fuzzy Matching بررسی شود.

### Index

```text
UNIQUE(product_id, normalized_alias)
INDEX(normalized_alias)
```

---

# 11) تغییر `product_update_logs`

جدول فعلی فقط تغییر قیمت/نام را ثبت می‌کند.

یک فیلد جدید اضافه شود:

```text
changes_json
```

مثال:

```json
{
  "price": {
    "old": 390000,
    "new": 485000
  },
  "wholesale_sale_mode": {
    "old": "bulk",
    "new": "bulk"
  },
  "wholesale_min_order_kg": {
    "old": 4,
    "new": 4
  },
  "wholesale_base_weight": {
    "old": 1,
    "new": 1
  },
  "wholesale_base_weight_unit": {
    "old": "kg",
    "new": "kg"
  }
}
```

هدف این است که هر تغییر Importable در یک Log قابل Audit باشد.

---

# 12) Migration پیشنهادی

## Migration 1: افزودن فیلدهای عمده‌فروشی به `products`

```php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('products', function (Blueprint $table) {
            $table->enum('wholesale_sale_mode', ['bulk', 'packaged'])
                ->nullable()
                ->after('dimensions');

            $table->decimal('wholesale_min_order_kg', 12, 3)
                ->nullable()
                ->after('wholesale_sale_mode');

            $table->unsignedInteger('wholesale_min_order_count')
                ->nullable()
                ->after('wholesale_min_order_kg');

            $table->decimal('wholesale_base_weight', 12, 3)
                ->nullable()
                ->after('wholesale_min_order_count');

            $table->enum('wholesale_base_weight_unit', ['kg', 'g'])
                ->nullable()
                ->after('wholesale_base_weight');

            $table->index(
                ['category_id', 'wholesale_sale_mode'],
                'products_wholesale_category_mode_idx'
            );
        });
    }

    public function down(): void
    {
        Schema::table('products', function (Blueprint $table) {
            $table->dropIndex('products_wholesale_category_mode_idx');

            $table->dropColumn([
                'wholesale_sale_mode',
                'wholesale_min_order_kg',
                'wholesale_min_order_count',
                'wholesale_base_weight',
                'wholesale_base_weight_unit',
            ]);
        });
    }
};
```

---

# 13) Migration جدول Alias

```php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('product_name_aliases', function (Blueprint $table) {
            $table->id();

            $table->foreignId('product_id')
                ->constrained('products')
                ->cascadeOnDelete();

            $table->string('alias', 255);

            $table->string('normalized_alias', 255);

            $table->string('source', 50)
                ->default('manual');

            $table->boolean('is_active')
                ->default(true);

            $table->timestamps();

            $table->unique(
                ['product_id', 'normalized_alias'],
                'product_alias_product_normalized_unique'
            );

            $table->index(
                'normalized_alias',
                'product_alias_normalized_idx'
            );
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('product_name_aliases');
    }
};
```

---

# 14) Migration مربوط به Import

```php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('price_list_imports', function (Blueprint $table) {
            $table->foreignId('category_scope_id')
                ->nullable()
                ->after('total_items')
                ->constrained('product_categories')
                ->nullOnDelete();

            $table->string('selected_price_source', 20)
                ->nullable()
                ->after('category_scope_id');

            $table->timestamp('applied_at')
                ->nullable()
                ->after('updated_at');

            $table->unsignedInteger('matched_items')
                ->nullable()
                ->after('applied_at');

            $table->unsignedInteger('updated_items')
                ->nullable()
                ->after('matched_items');

            $table->unsignedInteger('needs_review_items')
                ->nullable()
                ->after('updated_items');

            $table->unsignedInteger('unmatched_items')
                ->nullable()
                ->after('needs_review_items');
        });

        Schema::table('price_list_items', function (Blueprint $table) {
            $table->decimal('cash_price', 20, 2)
                ->nullable()
                ->after('price');

            $table->decimal('days45_price', 20, 2)
                ->nullable()
                ->after('cash_price');

            $table->string('extracted_sale_mode', 20)
                ->nullable()
                ->after('days45_price');

            $table->decimal('extracted_min_order_kg', 12, 3)
                ->nullable()
                ->after('extracted_sale_mode');

            $table->unsignedInteger('extracted_min_order_count')
                ->nullable()
                ->after('extracted_min_order_kg');

            $table->decimal('extracted_base_weight', 12, 3)
                ->nullable()
                ->after('extracted_min_order_count');

            $table->string('extracted_base_weight_unit', 10)
                ->nullable()
                ->after('extracted_base_weight');

            $table->string('source_description', 500)
                ->nullable()
                ->after('extracted_base_weight_unit');

            $table->string('price_source', 20)
                ->nullable()
                ->after('source_description');

            $table->string('match_method', 30)
                ->nullable()
                ->after('match_status');

            $table->decimal('parser_confidence', 5, 2)
                ->nullable()
                ->after('match_method');

            $table->json('raw_data')
                ->nullable()
                ->after('candidates_json');

            $table->index(
                ['import_id', 'match_status'],
                'price_items_import_status_idx'
            );
        });

        Schema::table('product_update_logs', function (Blueprint $table) {
            $table->json('changes_json')
                ->nullable()
                ->after('new_discount_price');
        });
    }

    public function down(): void
    {
        Schema::table('product_update_logs', function (Blueprint $table) {
            $table->dropColumn('changes_json');
        });

        Schema::table('price_list_items', function (Blueprint $table) {
            $table->dropIndex('price_items_import_status_idx');

            $table->dropColumn([
                'cash_price',
                'days45_price',
                'extracted_sale_mode',
                'extracted_min_order_kg',
                'extracted_min_order_count',
                'extracted_base_weight',
                'extracted_base_weight_unit',
                'source_description',
                'price_source',
                'match_method',
                'parser_confidence',
                'raw_data',
            ]);
        });

        Schema::table('price_list_imports', function (Blueprint $table) {
            $table->dropConstrainedForeignId('category_scope_id');

            $table->dropColumn([
                'selected_price_source',
                'applied_at',
                'matched_items',
                'updated_items',
                'needs_review_items',
                'unmatched_items',
            ]);
        });
    }
};
```

---

# 15) Legacy Data Migration

**Business logic داخل Migration انجام نشود.**

Migration فقط Schema را اضافه کند.

برای انتقال داده‌های قدیمی یک Artisan Command جدا ساخته شود:

```bash
php artisan wholesale:classify-legacy-products --dry-run
```

سپس:

```bash
php artisan wholesale:classify-legacy-products
```

### نکته

فیلدهای قدیمی قابل اعتماد نیستند. بنابراین Command باید:

1. محصولات موجود را بخواند.
2. بر اساس `name`، `description`، `minimum_weight` و `dimensions` پیشنهاد بدهد.
3. خروجی Dry Run تولید کند.
4. موارد نامطمئن را وارد Review کند.
5. بدون تأیید، داده‌ی مبهم را overwrite نکند.

---

# 16) وضعیت واقعی PDF ورودی

PDF نمونه‌ی فعلی یک فایل **اسکن‌شده** است و متن واقعی قابل اتکا در آن وجود ندارد.

در خود تصویر Footer عبارت:

```text
Page 1 of 3
```

دیده می‌شود، ولی فایل PDF آپلودشده از نظر ساختار واقعی فقط **۱ صفحه PDF** دارد.

بنابراین Importer نباید به Footer اعتماد کند و باید تعداد واقعی صفحات PDF را از خود فایل بگیرد.

---

# 17) ساختار اطلاعات PDF

PDF فعلی جدولی شبیه این دارد:

```text
نام محصول | ماندگاری | نقدی (پیش واریز) | 45 روزه | توضیحات
```

بنابراین Parser باید این موارد را جدا نگه دارد:

```text
original_name
cash_price
days45_price
shelf_life
source_description
```

در مرحله اول هیچ‌کدام با دیگری Merge نشوند.

---

# 18) OCR Pipeline

چون PDF ممکن است Scan/Image باشد، Text Extraction معمولی کافی نیست.

Architecture:

```text
Uploaded PDF
   ↓
Validate PDF
   ↓
Detect page count
   ↓
Render page as image
   ↓
OCR / Vision extraction
   ↓
Raw OCR text
   ↓
Table row parser
   ↓
Normalizer
   ↓
Structured PriceListItem
   ↓
Product matcher
   ↓
Preview
   ↓
Manual confirmation if needed
   ↓
Transaction
   ↓
Update products
```

### Interface پیشنهادی

```php
interface PriceListExtractorInterface
{
    /**
     * @return array<int, array<string, mixed>>
     */
    public function extract(string $filePath): array;
}
```

پیاده‌سازی‌ها:

```text
ScannedPdfOcrExtractor
TextPdfExtractor
```

و یک Service:

```php
PriceListImportService
```

که نوع Extractor را انتخاب کند.

---

# 19) OCR نباید مستقیماً دیتابیس را Update کند

این اشتباه است:

```text
OCR -> Product::update(...)
```

باید این‌طور باشد:

```text
OCR
 -> ParsedItem
 -> Normalize
 -> Match
 -> Review
 -> Apply
```

---

# 20) Parser برای واحدها

سیستم باید این‌ها را معادل بداند:

## Kilogram

```text
کیلو
کیلوگرم
کیلو گرم
کيلو
کيلوگرم
kg
KG
Kg
```

همه:

```text
kg
```

## Gram

```text
گرم
گرمی
گرم
g
G
gr
gram
```

همه:

```text
g
```

## Count

```text
عدد
عددی
تایی
تا
```

همه:

```text
count
```

---

# 21) نرمال‌سازی اعداد

باید همه این اعداد پشتیبانی شوند:

```text
۴۰۰
٤٠٠
400

۳.۵
۳٫۵
3.5
```

و این دو جدا شوند:

```text
3,500
3.5
```

قانون:

- `,` و `٬` در عددهای قیمتی/هزارگان باید در صورت کاربرد هزارگان حذف شوند.
- `٫` باید به `.` تبدیل شود.
- اعداد فارسی و عربی باید به ASCII تبدیل شوند.

### Helper

```php
function normalizeDigits(string $value): string
{
    return strtr($value, [
        '۰' => '0',
        '۱' => '1',
        '۲' => '2',
        '۳' => '3',
        '۴' => '4',
        '۵' => '5',
        '۶' => '6',
        '۷' => '7',
        '۸' => '8',
        '۹' => '9',

        '٠' => '0',
        '١' => '1',
        '٢' => '2',
        '٣' => '3',
        '٤' => '4',
        '٥' => '5',
        '٦' => '6',
        '٧' => '7',
        '٨' => '8',
        '٩' => '9',
    ]);
}
```

---

# 22) نرمال‌سازی متن فارسی

این تبدیل‌ها حتماً انجام شوند:

```text
ي => ی
ى => ی
ك => ک
ۀ => ه
ة => ه
ؤ/إ/أ => normalize as needed
```

همچنین:

- حذف اعراب
- حذف `ZWNJ` اضافی
- تبدیل چند Space به یک Space
- حذف Space ابتدا/انتها
- normalize punctuation
- جداکردن عدد از unit در صورت نیاز
- حذف نیم‌فاصله‌های غیرواقعی
- یکسان‌سازی `می‌` و `می`

مثلاً:

```text
ويفر ميكادو 800 گرم
```

باید به یک شکل canonical برسد:

```text
ویفر میکادو 800 گرم
```

---

# 23) Rule بسیار مهم برای `گرمی`

در Name Normalization نباید `گرمی` را کورکورانه حذف کرد.

مثلاً:

```text
ریس خشک شیری 400 گرمی
```

باید به:

```text
ریس خشک شیری 400 گرم
```

تبدیل شود، نه اینکه `400 گرم` حذف شود.

وزن و عدد بخشی از Identity محصول هستند.

---

# 24) تشخیص `bulk` و `packaged`

### اولویت اول: عبارت‌های صریح

`bulk` اگر:

```text
فله
```

در نام یا توضیحات وجود داشته باشد.

`packaged` اگر عبارات بسته‌ای/تعدادی مثل:

```text
عدد
عددی
تایی
جعبه
خانه
بسته‌بندی
سطلی
```

وجود داشته باشد.

### قانون مهم

اگر هر دو سیگنال با هم دیده شد:

```text
bulk + packaged
```

خودکار Update نکن.

```text
match_status = needs_review
```

### اگر هیچ سیگنال مطمئنی نبود

```text
match_status = needs_review
```

و مدل حدس نزند.

---

# 25) استخراج Minimum Order

## برای `bulk`

اگر متن شامل:

```text
2 کیلو
3 کیلو
3.5 کیلو
4 کیلوگرم
5 کیلو گرم
```

باشد:

```text
wholesale_min_order_kg = number
```

مثال:

```text
بسته ۴ کیلویی
```

=>:

```text
wholesale_min_order_kg = 4
```

ولی:

```text
base_weight = 1 kg
```

باقی می‌ماند.

## برای `packaged`

اگر متن شامل:

```text
10 عدد
12 عدد
15 عددی
18 عدد
6 جعبه
```

باشد:

```text
wholesale_min_order_count = number
```

---

# 26) استخراج Base Weight

## اگر bulk

طبق Business Rule فعلی:

```text
base_weight = 1
base_weight_unit = kg
```

حتی اگر Minimum Order برابر 3 یا 4 یا 5kg باشد.

## اگر packaged

به دنبال وزن صریح بگرد:

```text
400 گرم
600 گرم
700 گرم
800 گرم
900 گرم
1 کیلو
1.5 کیلو
```

مثلاً:

```text
لوکوم ... 600 گرمی
```

=>:

```text
base_weight = 600
base_weight_unit = g
```

و:

```text
نوقا ... 1.5 کیلو
```

=>:

```text
base_weight = 1.5
base_weight_unit = kg
```

### اگر وزن بسته‌ای پیدا نشد

حدس زده نشود.

```text
base_weight = null
```

و:

```text
needs_review = true
```

یا یک وضعیت مشخص مثل:

```text
missing_base_weight
```

---

# 27) مثال Canonical Entity

## مثال فله

```json
{
  "original_name": "لوکوم فله میوه ای 9 طعم تک طعم و مخلوط",
  "extracted_sale_mode": "bulk",
  "extracted_min_order_kg": 4.0,
  "extracted_min_order_count": null,
  "extracted_base_weight": 1.0,
  "extracted_base_weight_unit": "kg"
}
```

## مثال بسته‌ای

```json
{
  "original_name": "لوکوم فیتیل مغزدار کریستالی 600 گرمی",
  "extracted_sale_mode": "packaged",
  "extracted_min_order_kg": null,
  "extracted_min_order_count": 10,
  "extracted_base_weight": 600.0,
  "extracted_base_weight_unit": "g"
}
```

اگر تعداد 10 از منبع پیدا نشود، نباید حدس زده شود.

---

# 28) Product Matching

Match باید **چند مرحله‌ای** باشد.

## مرحله 1: Exact Normalized Match

```text
normalize(PDF name)
==
normalize(Product name)
```

اگر یکی بود:

```text
confirmed
match_method = exact
```

## مرحله 2: Alias Match

```text
normalized PDF name
==
product_name_aliases.normalized_alias
```

اگر یکی بود:

```text
confirmed
match_method = alias
```

## مرحله 3: Token Match

کلمات مهم را مقایسه کن.

کلمات مهم شامل:

- نام پایه
- طعم
- مغزدار/بدون مغز
- کریستالی
- فله
- بسته
- عدد
- وزن

## مرحله 4: Fuzzy Match

فازی باید آخرین مرحله باشد.

### Score

مثلاً:

```text
>= 95 => auto candidate
85 - 94.99 => needs_review
< 85 => unmatched
```

این Thresholdها Configurable باشند.

### قانون مهم

حتی با Score بالا، اگر دو محصول تقریباً مشابه باشند و تفاوت آن‌ها مثلاً:

```text
400 گرم
900 گرم
```

باشد، Match نباید براساس Score تنها تأیید شود.

---

# 29) Weight/Mode-aware Matching

بعد از Text Similarity، این موارد Cross-check شوند:

```text
PDF sale_mode
Product wholesale_sale_mode
```

و:

```text
PDF base weight
Product base weight
```

مثلاً:

```text
PDF = packaged, 400 g
Product A = packaged, 400 g
Product B = packaged, 900 g
```

حتی اگر متن Product B هم Score بالایی داشته باشد، Product A اولویت دارد.

---

# 30) Match Status

مقادیر پیشنهادی:

```text
pending
matched
needs_review
confirmed
unmatched
updated
failed
```

یا اگر ساختار ساده‌تر لازم است:

```text
pending
needs_review
confirmed
unmatched
updated
failed
```

---

# 31) Match نباید Product جدید بسازد

این Importer فقط باید:

```text
Existing Product -> Update
```

انجام دهد.

هرگز:

```text
Unmatched PDF row -> Create Product
```

خودکار انجام نشود.

---

# 32) فرآیند Import پیشنهادی

## Step 1

Admin PDF را Upload می‌کند.

## Step 2

`price_list_imports` ساخته می‌شود:

```text
status = processing
```

## Step 3

OCR اجرا می‌شود.

## Step 4

Rows استخراج می‌شوند.

## Step 5

Normalization.

## Step 6

ساخت `price_list_items`.

## Step 7

Match با محصولات عمده.

## Step 8

محاسبه:

```text
matched
needs_review
unmatched
```

## Step 9

Preview به Admin نمایش داده شود.

## Step 10

Admin موارد Review را تأیید/اصلاح کند.

## Step 11

فقط بعد از Apply:

```text
DB Transaction
```

شروع شود.

## Step 12

Product update.

## Step 13

Log update.

## Step 14

Alias ساختن برای Matchهای دستی تأییدشده.

## Step 15

Import:

```text
status = applied
applied_at = now()
```

---

# 33) Update Product

فقط وقتی:

```text
price_list_item.match_status = confirmed
```

باشد.

سپس:

```php
$product->update([
    'price' => $priceListItem->price,

    'wholesale_sale_mode' => $priceListItem->extracted_sale_mode,
    'wholesale_min_order_kg' => $priceListItem->extracted_min_order_kg,
    'wholesale_min_order_count' => $priceListItem->extracted_min_order_count,
    'wholesale_base_weight' => $priceListItem->extracted_base_weight,
    'wholesale_base_weight_unit' => $priceListItem->extracted_base_weight_unit,
]);
```

### اما null overwrite ممنوع

اگر Parser چیزی را پیدا نکرده:

```text
null
```

نباید حتماً مقدار قبلی Product را نابود کند.

قانون:

```text
Only update field when parser produced a trusted value.
```

---

# 34) `discount_price`

از این Importer تغییر نکند مگر اینکه صراحتاً Business Rule مربوط به تخفیف قیمت در PDF وجود داشته باشد.

PDF فعلی دو قیمت دارد که به‌عنوان Price Source نگه‌داری می‌شوند؛ این‌ها را با `discount_price` اشتباه نکن.

---

# 35) قیمت انتخابی

چون PDF دو قیمت دارد:

```text
cash_price
days45_price
```

Import باید یک `selected_price_source` داشته باشد:

```text
cash
45_day
```

و:

```text
price = selected value
```

این انتخاب باید در `price_list_imports` ذخیره شود.

---

# 36) Normalizer پیشنهادی

یک Service مستقل بساز:

```php
class PersianTextNormalizer
{
    public function normalize(string $value): string
    {
        // 1. Persian/Arabic characters
        // 2. digits
        // 3. punctuation
        // 4. ZWNJ
        // 5. whitespace
        // 6. units
        // 7. numeric-unit spacing
        // 8. lowercase Latin where applicable

        return trim($value);
    }
}
```

همچنین:

```php
class WeightParser
{
    public function parse(string $text): ?array
    {
        // [
        //   'value' => 400.0,
        //   'unit' => 'g'
        // ]
    }
}
```

و:

```php
class MinimumOrderParser
{
    public function parse(string $text, string $saleMode): ?array
    {
        // [
        //   'kg' => 4.0,
        //   'count' => null,
        // ]
    }
}
```

---

# 37) Product Match Service

```php
class PriceListProductMatcher
{
    public function match(
        string $originalName,
        ?string $saleMode,
        ?float $baseWeight,
        ?string $baseWeightUnit,
        int $categoryId
    ): MatchResult {
        // exact
        // alias
        // token
        // fuzzy
        // weighted checks
        // ambiguity handling
    }
}
```

`MatchResult` بهتر است شامل این موارد باشد:

```text
product_id
score
status
method
candidates
reasons
```

---

# 38) دلیل Match هم ذخیره شود

در `raw_data` یا `candidates_json` چیزی شبیه این ذخیره شود:

```json
{
  "match_method": "fuzzy",
  "score": 96.5,
  "selected_product_id": 250,
  "reasons": [
    "normalized_name_high_similarity",
    "same_sale_mode",
    "same_base_weight_unit"
  ],
  "candidates": [
    {
      "product_id": 250,
      "score": 96.5
    },
    {
      "product_id": 251,
      "score": 81.2
    }
  ]
}
```

---

# 39) نکته مهم درباره Score

در داده‌های فعلی نمونه‌هایی از Score با اعوجاج Float در JSON دیده می‌شود، مثلاً مقادیر شبیه:

```text
82.219999999...
```

Score هنگام ذخیره باید Round شود:

```php
round($score, 2)
```

تا در UI و JSON مقدارهای تمیز مثل:

```text
82.22
```

ذخیره شود.

---

# 40) Cart / Order

درخواست فعلی تمرکز روی Minimum Order است.

`carts.quantity` و `order_items.quantity` در دیتابیس فعلی `int` هستند.

در نسخه اول لازم نیست این دو را فقط به خاطر Base Weight تغییر دهی، **مگر اینکه قرار باشد مشتری بتواند وزن اعشاری مثل 1.5kg سفارش دهد**.

اگر Bulk سفارش اعشاری لازم شد، آن‌وقت یک Migration جدا بساز:

```text
quantity => decimal(12,3)
```

و Validation را بر اساس `wholesale_sale_mode` تنظیم کن.

تا قبل از آن، منطق فعلی Quantity را دست نزن.

---

# 41) Validation برای Product

### bulk

```php
[
    'wholesale_sale_mode' => ['required', 'in:bulk,packaged'],
    'wholesale_min_order_kg' => ['required_if:wholesale_sale_mode,bulk', 'numeric', 'min:0.001'],
    'wholesale_min_order_count' => ['nullable', 'prohibited_if:wholesale_sale_mode,bulk'],
    'wholesale_base_weight' => ['required', 'numeric', 'gt:0'],
    'wholesale_base_weight_unit' => ['required', 'in:kg,g'],
]
```

### packaged

```php
[
    'wholesale_sale_mode' => ['required', 'in:bulk,packaged'],
    'wholesale_min_order_count' => ['required_if:wholesale_sale_mode,packaged', 'integer', 'min:1'],
    'wholesale_min_order_kg' => ['nullable', 'prohibited_if:wholesale_sale_mode,packaged'],
    'wholesale_base_weight' => ['nullable', 'numeric', 'gt:0'],
    'wholesale_base_weight_unit' => ['nullable', 'in:kg,g'],
]
```

---

# 42) Cast در Model

در `Product`:

```php
protected $casts = [
    'wholesale_min_order_kg' => 'float',
    'wholesale_min_order_count' => 'integer',
    'wholesale_base_weight' => 'float',
];
```

در `PriceListItem`:

```php
protected $casts = [
    'cash_price' => 'decimal:2',
    'days45_price' => 'decimal:2',
    'price' => 'decimal:2',

    'extracted_min_order_kg' => 'float',
    'extracted_min_order_count' => 'integer',
    'extracted_base_weight' => 'float',

    'raw_data' => 'array',
    'candidates_json' => 'array',
];
```

---

# 43) Helper برای Base Weight

یک helper بساز:

```php
public function baseWeightInKg(): ?float
{
    if ($this->wholesale_base_weight === null) {
        return null;
    }

    return $this->wholesale_base_weight_unit === 'kg'
        ? (float) $this->wholesale_base_weight
        : (float) $this->wholesale_base_weight / 1000;
}
```

این helper امکان محاسبه‌های بعدی را ساده می‌کند.

مثلاً:

```text
400 g => 0.4 kg
1 kg => 1 kg
1.5 kg => 1.5 kg
```

---

# 44) محاسبه قیمت به ازای کیلو

در صورت نیاز برای گزارش/تحلیل:

```php
public function wholesalePricePerKg(): ?float
{
    $baseKg = $this->baseWeightInKg();

    if (!$baseKg || $baseKg <= 0) {
        return null;
    }

    return (float) $this->price / $baseKg;
}
```

این فقط برای تحلیل است.

نباید قیمت محصول را در DB به قیمت هر کیلو تبدیل کند، چون قیمت بسته‌ای باید همان قیمت بسته باقی بماند.

---

# 45) Admin UI مورد نیاز

صفحه:

```text
Admin > Price List Imports > Upload
```

فیلدها:

```text
PDF File
Category Scope
Price Source:
  - Cash (پیش واریز)
  - 45 Day
```

بعد از Parse:

جدول Preview:

```text
نام PDF
نام Normalize شده
محصول Match شده
Score
روش Match
نوع فروش
حداقل سفارش
Base Weight
قیمت نقدی
قیمت 45 روزه
قیمت انتخابی
Status
```

برای `needs_review`:

```text
[ انتخاب محصول ]
[ تایید Match ]
```

---

# 46) Dry Run

باید امکان:

```text
Parse + Match
```

بدون Update DB وجود داشته باشد.

مثلاً:

```bash
php artisan price-list:import storage/app/foo.pdf --dry-run
```

در Dry Run:

- هیچ Product update نشود.
- هیچ قیمت واقعی تغییر نکند.
- فقط `price_list_imports`/`price_list_items` قابل ذخیره باشند یا حتی در صورت نیاز به‌صورت temporary پردازش شوند.
- گزارش کامل تولید شود.

---

# 47) Apply

بعد از Review:

```bash
php artisan price-list:apply {importId}
```

این Command:

1. Import را validate کند.
2. فقط `confirmed`ها را انتخاب کند.
3. Transaction باز کند.
4. Productها را Update کند.
5. `product_update_logs` را ثبت کند.
6. Aliasها را ایجاد کند.
7. Import را `applied` کند.
8. Summary را ذخیره کند.

---

# 48) Transaction

کل Apply باید Transaction داشته باشد:

```php
DB::transaction(function () {
    // lock import
    // update products
    // insert logs
    // create aliases
    // update counters
    // mark import applied
});
```

برای جلوگیری از Apply دوباره:

```text
اگر status = applied
=> عملیات دوباره مجاز نیست.
```

---

# 49) Concurrency

هنگام Apply:

```php
$import = PriceListImport::query()
    ->whereKey($id)
    ->lockForUpdate()
    ->firstOrFail();
```

و وضعیت بررسی شود.

---

# 50) Security در Upload

فقط:

```text
application/pdf
```

قبول شود.

همچنین:

- Max file size
- ذخیره در Storage خصوصی
- نام فایل واقعی کاربر به‌عنوان Path استفاده نشود
- برای OCR مسیر موقت امن ساخته شود
- دستورات Shell با argument escaping امن اجرا شوند
- فایل موقت بعد از پایان حذف شود

---

# 51) تست‌های اجباری

## Unit Test برای Unit Normalization

این‌ها همه باید `kg` بدهند:

```text
کیلو
کیلوگرم
کیلو گرم
کيلو
kg
KG
```

و این‌ها `g`:

```text
گرم
گرمی
g
gr
```

## Number Tests

```text
۴۰۰ -> 400
٤٠٠ -> 400
400 -> 400
۳.۵ -> 3.5
۳٫۵ -> 3.5
```

## Name Tests

```text
ويفر ميكادو 800 گرم
ویفر میکادو ۸۰۰ گرمی
```

باید به canonical value نزدیک/یکسان برسند.

## Product Matching Tests

حداقل تست برای:

```text
Arabic/Persian chars
Weight difference
Bulk vs packaged
400g vs 900g
With nuts vs without nuts
```

---

# 52) تست واقعی روی PDF فعلی

برای PDF نمونه باید چند سناریو حتماً تست شوند:

### Bulk examples

نمونه‌هایی با:

```text
فله
```

و توضیحات:

```text
بسته 2 کیلویی
سه کیلوگرم
چهار کیلوگرم
پنج کیلوگرم
```

باید:

```text
sale_mode = bulk
minimum_order_unit = kg
base_weight = 1 kg
```

را درست بسازند.

### Packaged examples

نمونه‌های:

```text
400 گرم
600 گرم
700 گرم
800 گرم
900 گرم
```

و موارد تعدادی مثل:

```text
عدد
عددی
جعبه
خانه
```

باید:

```text
sale_mode = packaged
minimum_order_unit = count
base_weight = parsed weight
```

را درست بسازند.

---

# 53) نکته مهم درباره `minimum_order_count`

برای بسته‌ای ممکن است PDF وزن را بدهد ولی تعداد حداقل سفارش را ندهد.

در این حالت:

```text
minimum_order_count = null
```

و Match/Apply نباید به‌صورت کورکورانه مقدار `1` بگذارد.

اگر Business Rule پروژه این باشد که نبودن مقدار یعنی `1`، این Rule باید صریحاً در Config تعریف شود؛ حدس داخل Parser ممنوع است.

---

# 54) قیمت و Weight نباید با هم قاطی شوند

قیمت:

```text
decimal(20,2)
```

وزن:

```text
decimal(12,3)
```

در JSON/API:

```text
weight => float
price => decimal/numeric
```

---

# 55) Repository / Service Separation

منطق را در Controller نگذار.

پیشنهاد:

```text
PriceListImportService
PriceListExtractorInterface
ScannedPdfOcrExtractor
PriceListRowParser
PersianTextNormalizer
WeightParser
MinimumOrderParser
PriceListProductMatcher
PriceListApplyService
ProductAliasService
```

Controller فقط orchestration کند.

---

# 56) پیشنهاد ساختار فولدر

```text
app/
  Services/
    PriceList/
      PriceListImportService.php
      PriceListApplyService.php
      PriceListRowParser.php
      PersianTextNormalizer.php
      WeightParser.php
      MinimumOrderParser.php
      PriceListProductMatcher.php
      ProductAliasService.php

  Contracts/
    PriceList/
      PriceListExtractorInterface.php

  Extractors/
    PriceList/
      ScannedPdfOcrExtractor.php
      TextPdfExtractor.php
```

---

# 57) رفتار مورد انتظار نهایی

وقتی Admin این PDF را Upload کند:

```text
PDF
↓
OCR
↓
Extract:
  Name
  Cash price
  45-day price
  Description
↓
Normalize
↓
Detect:
  bulk / packaged
↓
Extract:
  minimum kg OR minimum count
  base weight
↓
Match product
↓
Preview
↓
Confirm
↓
Update:
  price
  wholesale_sale_mode
  wholesale_min_order_kg
  wholesale_min_order_count
  wholesale_base_weight
  wholesale_base_weight_unit
↓
Log
↓
Alias
```

---

# 58) Rules قطعی

این موارد را تغییر نده:

### Rule 1
فله:

```text
minimum_order_kg
```

### Rule 2
بسته‌ای:

```text
minimum_order_count
```

### Rule 3
Base Weight فقط:

```text
kg
g
```

### Rule 4
Base Weight برای Bulk:

```text
1 kg
```

### Rule 5
Base Weight برای Packaged از وزن واقعی بسته استخراج شود.

### Rule 6
اگر اطلاعات کافی نیست:

```text
null + needs_review
```

### Rule 7
واحدهای:

```text
کیلو
کیلوگرم
کیلو گرم
kg
```

یکسان باشند.

### Rule 8
واحدهای:

```text
گرم
گرمی
g
gr
```

یکسان باشند.

### Rule 9
اعداد فارسی و عربی normalize شوند.

### Rule 10
`minimum_weight` و `dimensions` منبع اصلی منطق جدید نباشند.

### Rule 11
Product جدید از PDF خودکار ساخته نشود.

### Rule 12
Fuzzy Match به‌تنهایی مجوز Update نیست اگر Ambiguity وجود دارد.

### Rule 13
`cash_price` و `days45_price` هر دو ذخیره شوند.

### Rule 14
فقط `selected_price_source` روی `products.price` اعمال شود.

### Rule 15
Apply حتماً Transactional و Idempotent باشد.

---

# 59) یک نکته مهم برای پیاده‌سازی نهایی

در سیستم فعلی، Match و Import از قبل وجود دارد؛ هدف **Rewrite کامل سیستم از صفر نیست**.

هدف:

```text
Upgrade existing import system
+
Fix normalization
+
Fix matching
+
Add wholesale sale mode
+
Add two minimum-order dimensions
+
Add base weight
+
Make PDF OCR reliable
+
Add preview/review
+
Add alias memory
```

بنابراین قبل از حذف/rename هر فیلد Legacy، تمام Referenceهای آن در پروژه Laravel با search بررسی شود.

مثلاً:

```bash
grep -R "minimum_weight" app resources routes
grep -R "dimensions" app resources routes
grep -R "weight" app resources routes
```

بعد از اطمینان از جایگزینی کامل، فیلدهای Legacy قابل حذف هستند.

---

# 60) Deliverables مورد انتظار از AI

AI باید این خروجی‌ها را تحویل دهد:

1. Migrationهای کامل.
2. Model changes + casts.
3. Extractor abstraction.
4. OCR implementation برای PDF Scan.
5. Persian normalizer.
6. Weight parser.
7. Minimum order parser.
8. Product matcher.
9. Alias system.
10. Import service.
11. Apply service.
12. Admin preview/review UI یا Livewire implementation متناسب با پروژه.
13. Validation.
14. Feature/Unit tests.
15. Artisan commands:
   - `price-list:import`
   - `price-list:apply`
   - `wholesale:classify-legacy-products`
16. گزارش Matchهای:
   - confirmed
   - needs_review
   - unmatched
17. Logging کامل تغییرات.
18. عدم ایجاد Product جدید از روی PDF.

---

# 61) Definition of Done

پیاده‌سازی زمانی کامل محسوب شود که:

- PDF اسکن‌شده بدون متن قابل استخراج هم قابل Import باشد.
- `کیلو`، `کیلوگرم`، `کیلو گرم`، `kg` یکسان شناخته شوند.
- `گرم`، `گرمی`، `g`، `gr` یکسان شناخته شوند.
- اعداد فارسی/عربی به عدد استاندارد تبدیل شوند.
- فله و بسته‌ای اشتباه با هم Match نشوند.
- حداقل سفارش فله با kg ذخیره شود.
- حداقل سفارش بسته‌ای با count ذخیره شود.
- Base Weight فقط kg/g باشد.
- Base Weight فله به‌طور پیش‌فرض 1kg باشد.
- Base Weight بسته‌ای از وزن محصول استخراج شود.
- در صورت ابهام، Update خودکار انجام نشود.
- هر Import قابل Preview باشد.
- هر Apply قابل Audit باشد.
- قیمت‌ها بر اساس Price Source انتخاب‌شده اعمال شوند.
- Match دستی به Alias دائمی تبدیل شود.
- Apply دوباره یک Import انجام نشود.
- تمام تغییرات در Transaction انجام شوند.

