phucsd commited on
Commit
973422f
·
1 Parent(s): 89569c6

feat: cap nhat bang gia in nhanh kem giay INTC va in gia cong Konica C12000

Browse files
.gitignore CHANGED
@@ -45,3 +45,6 @@ data/*.db-wal
45
  data/*.db-shm
46
  data/*.db-journal
47
 
 
 
 
 
45
  data/*.db-shm
46
  data/*.db-journal
47
 
48
+ # extracted images
49
+ scripts/extracted_images/
50
+
data/digital_pricing_giacong.json ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "machine_name": "KONICA C12000 / C12010S 5 MÀU",
4
+ "sheet_size": "A4",
5
+ "width_mm": 210,
6
+ "height_mm": 297,
7
+ "paper_lt_249": 600,
8
+ "paper_250_349": 800,
9
+ "paper_350_450": 1000,
10
+ "decal_paper_plastic": 1200,
11
+ "decal_clear": 1500,
12
+ "synthetic_paper": 1000,
13
+ "pvc_plastic": 1500
14
+ },
15
+ {
16
+ "machine_name": "KONICA C12000 / C12010S 5 MÀU",
17
+ "sheet_size": "330x355",
18
+ "width_mm": 330,
19
+ "height_mm": 355,
20
+ "paper_lt_249": 800,
21
+ "paper_250_349": 1000,
22
+ "paper_350_450": 1800,
23
+ "decal_paper_plastic": 1500,
24
+ "decal_clear": 1700,
25
+ "synthetic_paper": 1800,
26
+ "pvc_plastic": 2000
27
+ },
28
+ {
29
+ "machine_name": "KONICA C12000 / C12010S 5 MÀU",
30
+ "sheet_size": "A3",
31
+ "width_mm": 297,
32
+ "height_mm": 420,
33
+ "paper_lt_249": 1200,
34
+ "paper_250_349": 1600,
35
+ "paper_350_450": 2100,
36
+ "decal_paper_plastic": 2100,
37
+ "decal_clear": 2300,
38
+ "synthetic_paper": 2100,
39
+ "pvc_plastic": 2500
40
+ },
41
+ {
42
+ "machine_name": "KONICA C12000 / C12010S 5 MÀU",
43
+ "sheet_size": "330x483",
44
+ "width_mm": 330,
45
+ "height_mm": 483,
46
+ "paper_lt_249": 2000,
47
+ "paper_250_349": 2500,
48
+ "paper_350_450": 2700,
49
+ "decal_paper_plastic": 2500,
50
+ "decal_clear": 2800,
51
+ "synthetic_paper": 2700,
52
+ "pvc_plastic": 3000
53
+ },
54
+ {
55
+ "machine_name": "KONICA C12000 / C12010S 5 MÀU",
56
+ "sheet_size": "330x1200",
57
+ "width_mm": 330,
58
+ "height_mm": 1200,
59
+ "paper_lt_249": 4000,
60
+ "paper_250_349": 7000,
61
+ "paper_350_450": 10000,
62
+ "decal_paper_plastic": 6000,
63
+ "decal_clear": 7500,
64
+ "synthetic_paper": 15000,
65
+ "pvc_plastic": 0
66
+ }
67
+ ]
data/digital_pricing_kem_giay.json ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "paper_code": "C100",
4
+ "paper_name": "Couche 100gsm",
5
+ "gsm": 100,
6
+ "sheet_size": "325x430",
7
+ "width_mm": 325,
8
+ "height_mm": 430,
9
+ "price_1side": 1600,
10
+ "price_2side": 2800
11
+ },
12
+ {
13
+ "paper_code": "C120",
14
+ "paper_name": "Couche 120gsm",
15
+ "gsm": 120,
16
+ "sheet_size": "325x430",
17
+ "width_mm": 325,
18
+ "height_mm": 430,
19
+ "price_1side": 1700,
20
+ "price_2side": 2900
21
+ },
22
+ {
23
+ "paper_code": "C150",
24
+ "paper_name": "Couche 150gsm",
25
+ "gsm": 150,
26
+ "sheet_size": "325x430",
27
+ "width_mm": 325,
28
+ "height_mm": 430,
29
+ "price_1side": 1800,
30
+ "price_2side": 3000
31
+ },
32
+ {
33
+ "paper_code": "C200",
34
+ "paper_name": "Couche 200gsm",
35
+ "gsm": 200,
36
+ "sheet_size": "325x430",
37
+ "width_mm": 325,
38
+ "height_mm": 430,
39
+ "price_1side": 1900,
40
+ "price_2side": 3100
41
+ },
42
+ {
43
+ "paper_code": "C250",
44
+ "paper_name": "Couche 250gsm",
45
+ "gsm": 250,
46
+ "sheet_size": "325x430",
47
+ "width_mm": 325,
48
+ "height_mm": 430,
49
+ "price_1side": 2100,
50
+ "price_2side": 3300
51
+ },
52
+ {
53
+ "paper_code": "C300",
54
+ "paper_name": "Couche 300gsm",
55
+ "gsm": 300,
56
+ "sheet_size": "325x430",
57
+ "width_mm": 325,
58
+ "height_mm": 430,
59
+ "price_1side": 2700,
60
+ "price_2side": 4300
61
+ },
62
+ {
63
+ "paper_code": "I250",
64
+ "paper_name": "Ivory 250gsm",
65
+ "gsm": 250,
66
+ "sheet_size": "325x430",
67
+ "width_mm": 325,
68
+ "height_mm": 430,
69
+ "price_1side": 2100,
70
+ "price_2side": 3300
71
+ },
72
+ {
73
+ "paper_code": "I300",
74
+ "paper_name": "Ivory 300gsm",
75
+ "gsm": 300,
76
+ "sheet_size": "325x430",
77
+ "width_mm": 325,
78
+ "height_mm": 430,
79
+ "price_1side": 2600,
80
+ "price_2side": 4200
81
+ },
82
+ {
83
+ "paper_code": "I350",
84
+ "paper_name": "Ivory 350gsm",
85
+ "gsm": 350,
86
+ "sheet_size": "325x430",
87
+ "width_mm": 325,
88
+ "height_mm": 430,
89
+ "price_1side": 3300,
90
+ "price_2side": 5300
91
+ },
92
+ {
93
+ "paper_code": "F100",
94
+ "paper_name": "Fort 100gsm",
95
+ "gsm": 100,
96
+ "sheet_size": "325x430",
97
+ "width_mm": 325,
98
+ "height_mm": 430,
99
+ "price_1side": 1600,
100
+ "price_2side": 2800
101
+ },
102
+ {
103
+ "paper_code": "F120",
104
+ "paper_name": "Fort 120gsm",
105
+ "gsm": 120,
106
+ "sheet_size": "325x430",
107
+ "width_mm": 325,
108
+ "height_mm": 430,
109
+ "price_1side": 1700,
110
+ "price_2side": 2900
111
+ },
112
+ {
113
+ "paper_code": "F180",
114
+ "paper_name": "Fort 180gsm",
115
+ "gsm": 180,
116
+ "sheet_size": "325x430",
117
+ "width_mm": 325,
118
+ "height_mm": 430,
119
+ "price_1side": 1900,
120
+ "price_2side": 3100
121
+ },
122
+ {
123
+ "paper_code": "F230",
124
+ "paper_name": "Fort 230gsm",
125
+ "gsm": 230,
126
+ "sheet_size": "325x430",
127
+ "width_mm": 325,
128
+ "height_mm": 430,
129
+ "price_1side": 2100,
130
+ "price_2side": 3300
131
+ },
132
+ {
133
+ "paper_code": "F250",
134
+ "paper_name": "Fort 250gsm",
135
+ "gsm": 250,
136
+ "sheet_size": "325x430",
137
+ "width_mm": 325,
138
+ "height_mm": 430,
139
+ "price_1side": 2300,
140
+ "price_2side": 3500
141
+ },
142
+ {
143
+ "paper_code": "F180_355",
144
+ "paper_name": "Fort 180gsm (325x355)",
145
+ "gsm": 180,
146
+ "sheet_size": "325x355",
147
+ "width_mm": 325,
148
+ "height_mm": 355,
149
+ "price_1side": 1400,
150
+ "price_2side": 2200
151
+ },
152
+ {
153
+ "paper_code": "F230_355",
154
+ "paper_name": "Fort 230gsm (325x355)",
155
+ "gsm": 230,
156
+ "sheet_size": "325x355",
157
+ "width_mm": 325,
158
+ "height_mm": 355,
159
+ "price_1side": 1500,
160
+ "price_2side": 2300
161
+ }
162
+ ]
scripts/extract_pdf_images.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fitz
2
+ import os
3
+
4
+ pdf1_path = r"C:\Users\admin\Downloads\BẢNG GIÁ IN NHANH 14.03.2026.pdf"
5
+ pdf2_path = r"C:\Users\admin\Downloads\BẢNG GIÁ IN GIA CÔNG 26.02.2026.pdf"
6
+
7
+ os.makedirs("scripts/extracted_images", exist_ok=True)
8
+
9
+ # Extract PDF 1 Page 1
10
+ doc1 = fitz.open(pdf1_path)
11
+ page1 = doc1[0]
12
+ pix1 = page1.get_pixmap(dpi=150)
13
+ pix1.save("scripts/extracted_images/in_nhanh_page1.png")
14
+ print("Saved in_nhanh_page1.png")
15
+
16
+ # Extract PDF 2 Page 2
17
+ doc2 = fitz.open(pdf2_path)
18
+ page2 = doc2[1] # 0-indexed page 2
19
+ pix2 = page2.get_pixmap(dpi=150)
20
+ pix2.save("scripts/extracted_images/in_gia_cong_page2.png")
21
+ print("Saved in_gia_cong_page2.png")
22
+
23
+ # Also let's extract page 1 and page 3 of PDF 2 just in case
24
+ doc2[0].get_pixmap(dpi=100).save("scripts/extracted_images/in_gia_cong_page1.png")
25
+ doc2[2].get_pixmap(dpi=100).save("scripts/extracted_images/in_gia_cong_page3.png")
26
+ print("Saved in_gia_cong_page1.png and page3.png")
scripts/inspect_pdf_details.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fitz
2
+ import sys
3
+
4
+ sys.stdout.reconfigure(encoding='utf-8')
5
+
6
+ for path, name in [
7
+ (r"C:\Users\admin\Downloads\BẢNG GIÁ IN NHANH 14.03.2026.pdf", "PDF 1"),
8
+ (r"C:\Users\admin\Downloads\BẢNG GIÁ IN GIA CÔNG 26.02.2026.pdf", "PDF 2")
9
+ ]:
10
+ print(f"\n*** Checking {name}: {path} ***")
11
+ doc = fitz.open(path)
12
+ for p_idx, page in enumerate(doc):
13
+ imgs = page.get_images()
14
+ text = page.get_text()
15
+ drawings = page.get_drawings()
16
+ print(f"Page {p_idx+1}: text_len={len(text)}, images_count={len(imgs)}, drawings_count={len(drawings)}")
17
+ for img_idx, img in enumerate(imgs):
18
+ xref = img[0]
19
+ base_img = doc.extract_image(xref)
20
+ print(f" Image {img_idx+1}: xref={xref}, ext={base_img['ext']}, width={base_img['width']}, height={base_img['height']}")
scripts/inspect_pdfs.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fitz
2
+ import sys
3
+
4
+ sys.stdout.reconfigure(encoding='utf-8')
5
+
6
+ pdf1_path = r"C:\Users\admin\Downloads\BẢNG GIÁ IN NHANH 14.03.2026.pdf"
7
+ pdf2_path = r"C:\Users\admin\Downloads\BẢNG GIÁ IN GIA CÔNG 26.02.2026.pdf"
8
+
9
+ print("==================================================")
10
+ print("=== PDF 1: BẢNG GIÁ IN NHANH 14.03.2026.pdf ===")
11
+ print("==================================================")
12
+ try:
13
+ doc1 = fitz.open(pdf1_path)
14
+ print("Number of pages in PDF 1:", len(doc1))
15
+ for i, page in enumerate(doc1):
16
+ print(f"\n--- PDF 1 - Page {i+1} ---")
17
+ text = page.get_text()
18
+ print(text[:2000])
19
+ if len(text) > 2000:
20
+ print(f"... [Total length: {len(text)} chars]")
21
+ except Exception as e:
22
+ print("Error reading PDF 1:", e)
23
+
24
+ print("\n==================================================")
25
+ print("=== PDF 2: BẢNG GIÁ IN GIA CÔNG 26.02.2026.pdf ===")
26
+ print("==================================================")
27
+ try:
28
+ doc2 = fitz.open(pdf2_path)
29
+ print("Number of pages in PDF 2:", len(doc2))
30
+ for i, page in enumerate(doc2):
31
+ print(f"\n--- PDF 2 - Page {i+1} ---")
32
+ text = page.get_text()
33
+ print(text[:2000])
34
+ if len(text) > 2000:
35
+ print(f"... [Total length: {len(text)} chars]")
36
+ except Exception as e:
37
+ print("Error reading PDF 2:", e)
scripts/setup_digital_pricing.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import json
3
+ import os
4
+
5
+ db_path = "data/printcal.db"
6
+ conn = sqlite3.connect(db_path)
7
+ cur = conn.cursor()
8
+
9
+ # 1. Create digital_pricing_giacong
10
+ cur.execute("""
11
+ CREATE TABLE IF NOT EXISTS digital_pricing_giacong (
12
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
13
+ machine_name TEXT NOT NULL,
14
+ sheet_size TEXT NOT NULL UNIQUE,
15
+ width_mm REAL NOT NULL,
16
+ height_mm REAL NOT NULL,
17
+ paper_lt_249 REAL NOT NULL,
18
+ paper_250_349 REAL NOT NULL,
19
+ paper_350_450 REAL NOT NULL,
20
+ decal_paper_plastic REAL NOT NULL,
21
+ decal_clear REAL NOT NULL,
22
+ synthetic_paper REAL NOT NULL,
23
+ pvc_plastic REAL NOT NULL,
24
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
25
+ );
26
+ """)
27
+
28
+ # 2. Create digital_pricing_kem_giay
29
+ cur.execute("""
30
+ CREATE TABLE IF NOT EXISTS digital_pricing_kem_giay (
31
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
32
+ paper_code TEXT NOT NULL UNIQUE,
33
+ paper_name TEXT NOT NULL,
34
+ gsm INTEGER NOT NULL,
35
+ sheet_size TEXT NOT NULL,
36
+ width_mm REAL NOT NULL,
37
+ height_mm REAL NOT NULL,
38
+ price_1side REAL NOT NULL,
39
+ price_2side REAL NOT NULL,
40
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
41
+ );
42
+ """)
43
+
44
+ # 3. Data for In Gia Cong (Konica C12000 / C12010S 5 Mau - Trang 2 PDF)
45
+ giacong_data = [
46
+ # machine_name, sheet_size, w, h, lt249, 250-349, 350-450, decal, decal_trong, giay_nhua, pvc
47
+ ("KONICA C12000 / C12010S 5 MÀU", "A4", 210, 297, 600, 800, 1000, 1200, 1500, 1000, 1500),
48
+ ("KONICA C12000 / C12010S 5 MÀU", "330x355", 330, 355, 800, 1000, 1800, 1500, 1700, 1800, 2000),
49
+ ("KONICA C12000 / C12010S 5 MÀU", "A3", 297, 420, 1200, 1600, 2100, 2100, 2300, 2100, 2500),
50
+ ("KONICA C12000 / C12010S 5 MÀU", "330x483", 330, 483, 2000, 2500, 2700, 2500, 2800, 2700, 3000),
51
+ ("KONICA C12000 / C12010S 5 MÀU", "330x1200", 330, 1200, 4000, 7000, 10000, 6000, 7500, 15000, 0),
52
+ ]
53
+
54
+ cur.execute("DELETE FROM digital_pricing_giacong;")
55
+ cur.executemany("""
56
+ INSERT INTO digital_pricing_giacong (machine_name, sheet_size, width_mm, height_mm, paper_lt_249, paper_250_349, paper_350_450, decal_paper_plastic, decal_clear, synthetic_paper, pvc_plastic)
57
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
58
+ """, giacong_data)
59
+
60
+ # 4. Data for In Nhanh Kem Giay (INTC - PDF In Nhanh 14.03.2026)
61
+ kem_giay_data = [
62
+ # code, name, gsm, sheet_size, w, h, p1, p2
63
+ ("C100", "Couche 100gsm", 100, "325x430", 325, 430, 1600, 2800),
64
+ ("C120", "Couche 120gsm", 120, "325x430", 325, 430, 1700, 2900),
65
+ ("C150", "Couche 150gsm", 150, "325x430", 325, 430, 1800, 3000),
66
+ ("C200", "Couche 200gsm", 200, "325x430", 325, 430, 1900, 3100),
67
+ ("C250", "Couche 250gsm", 250, "325x430", 325, 430, 2100, 3300),
68
+ ("C300", "Couche 300gsm", 300, "325x430", 325, 430, 2700, 4300),
69
+ ("I250", "Ivory 250gsm", 250, "325x430", 325, 430, 2100, 3300),
70
+ ("I300", "Ivory 300gsm", 300, "325x430", 325, 430, 2600, 4200),
71
+ ("I350", "Ivory 350gsm", 350, "325x430", 325, 430, 3300, 5300),
72
+ ("F100", "Fort 100gsm", 100, "325x430", 325, 430, 1600, 2800),
73
+ ("F120", "Fort 120gsm", 120, "325x430", 325, 430, 1700, 2900),
74
+ ("F180", "Fort 180gsm", 180, "325x430", 325, 430, 1900, 3100),
75
+ ("F230", "Fort 230gsm", 230, "325x430", 325, 430, 2100, 3300),
76
+ ("F250", "Fort 250gsm", 250, "325x430", 325, 430, 2300, 3500),
77
+ ("F180_355", "Fort 180gsm (325x355)", 180, "325x355", 325, 355, 1400, 2200),
78
+ ("F230_355", "Fort 230gsm (325x355)", 230, "325x355", 325, 355, 1500, 2300),
79
+ ]
80
+
81
+ cur.execute("DELETE FROM digital_pricing_kem_giay;")
82
+ cur.executemany("""
83
+ INSERT INTO digital_pricing_kem_giay (paper_code, paper_name, gsm, sheet_size, width_mm, height_mm, price_1side, price_2side)
84
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?);
85
+ """, kem_giay_data)
86
+
87
+ conn.commit()
88
+ print("Saved tables to SQLite successfully!")
89
+
90
+ # Export to JSON
91
+ with open("data/digital_pricing_giacong.json", "w", encoding="utf-8") as f:
92
+ json.dump([
93
+ {
94
+ "machine_name": r[0], "sheet_size": r[1], "width_mm": r[2], "height_mm": r[3],
95
+ "paper_lt_249": r[4], "paper_250_349": r[5], "paper_350_450": r[6],
96
+ "decal_paper_plastic": r[7], "decal_clear": r[8], "synthetic_paper": r[9], "pvc_plastic": r[10]
97
+ }
98
+ for r in giacong_data
99
+ ], f, ensure_ascii=False, indent=2)
100
+
101
+ with open("data/digital_pricing_kem_giay.json", "w", encoding="utf-8") as f:
102
+ json.dump([
103
+ {
104
+ "paper_code": r[0], "paper_name": r[1], "gsm": r[2], "sheet_size": r[3],
105
+ "width_mm": r[4], "height_mm": r[5], "price_1side": r[6], "price_2side": r[7]
106
+ }
107
+ for r in kem_giay_data
108
+ ], f, ensure_ascii=False, indent=2)
109
+
110
+ print("Exported JSON files successfully!")
scripts/update_cost_calculator.py ADDED
@@ -0,0 +1,690 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ new_content = """import {
2
+ CalculationInput,
3
+ CalculationResult,
4
+ ComparisonItem,
5
+ DigitalMachine,
6
+ DigitalMode,
7
+ DigitalPrintGiaCong,
8
+ DigitalPrintKemGiay,
9
+ FinishingCostDetail,
10
+ FinishingService,
11
+ OffsetMachine,
12
+ PaperType,
13
+ } from '@/types';
14
+ import { calculateImposition } from './imposition';
15
+
16
+ export const DEFAULT_DIGITAL_GIA_CONG: DigitalPrintGiaCong[] = [
17
+ { id: 1, machineName: 'KONICA C12000 / C12010S 5 MÀU', sheetSize: 'A4', widthMm: 210, heightMm: 297, paperLt249: 600, paper250To349: 800, paper350To450: 1000, decalPaperPlastic: 1200, decalClear: 1500, syntheticPaper: 1000, pvcPlastic: 1500 },
18
+ { id: 2, machineName: 'KONICA C12000 / C12010S 5 MÀU', sheetSize: '330x355', widthMm: 330, heightMm: 355, paperLt249: 800, paper250To349: 1000, paper350To450: 1800, decalPaperPlastic: 1500, decalClear: 1700, syntheticPaper: 1800, pvcPlastic: 2000 },
19
+ { id: 3, machineName: 'KONICA C12000 / C12010S 5 MÀU', sheetSize: 'A3', widthMm: 297, heightMm: 420, paperLt249: 1200, paper250To349: 1600, paper350To450: 2100, decalPaperPlastic: 2100, decalClear: 2300, syntheticPaper: 2100, pvcPlastic: 2500 },
20
+ { id: 4, machineName: 'KONICA C12000 / C12010S 5 MÀU', sheetSize: '330x483', widthMm: 330, heightMm: 483, paperLt249: 2000, paper250To349: 2500, paper350To450: 2700, decalPaperPlastic: 2500, decalClear: 2800, syntheticPaper: 2700, pvcPlastic: 3000 },
21
+ { id: 5, machineName: 'KONICA C12000 / C12010S 5 MÀU', sheetSize: '330x1200', widthMm: 330, heightMm: 1200, paperLt249: 4000, paper250To349: 7000, paper350To450: 10000, decalPaperPlastic: 6000, decalClear: 7500, syntheticPaper: 15000, pvcPlastic: 0 },
22
+ ];
23
+
24
+ export const DEFAULT_DIGITAL_KEM_GIAY: DigitalPrintKemGiay[] = [
25
+ { id: 1, paperCode: 'C100', paperName: 'Couche 100gsm', gsm: 100, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1600, price2Side: 2800 },
26
+ { id: 2, paperCode: 'C120', paperName: 'Couche 120gsm', gsm: 120, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1700, price2Side: 2900 },
27
+ { id: 3, paperCode: 'C150', paperName: 'Couche 150gsm', gsm: 150, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1800, price2Side: 3000 },
28
+ { id: 4, paperCode: 'C200', paperName: 'Couche 200gsm', gsm: 200, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1900, price2Side: 3100 },
29
+ { id: 5, paperCode: 'C250', paperName: 'Couche 250gsm', gsm: 250, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2100, price2Side: 3300 },
30
+ { id: 6, paperCode: 'C300', paperName: 'Couche 300gsm', gsm: 300, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2700, price2Side: 4300 },
31
+ { id: 7, paperCode: 'I250', paperName: 'Ivory 250gsm', gsm: 250, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2100, price2Side: 3300 },
32
+ { id: 8, paperCode: 'I300', paperName: 'Ivory 300gsm', gsm: 300, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2600, price2Side: 4200 },
33
+ { id: 9, paperCode: 'I350', paperName: 'Ivory 350gsm', gsm: 350, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 3300, price2Side: 5300 },
34
+ { id: 10, paperCode: 'F100', paperName: 'Fort 100gsm', gsm: 100, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1600, price2Side: 2800 },
35
+ { id: 11, paperCode: 'F120', paperName: 'Fort 120gsm', gsm: 120, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1700, price2Side: 2900 },
36
+ { id: 12, paperCode: 'F180', paperName: 'Fort 180gsm', gsm: 180, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1900, price2Side: 3100 },
37
+ { id: 13, paperCode: 'F230', paperName: 'Fort 230gsm', gsm: 230, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2100, price2Side: 3300 },
38
+ { id: 14, paperCode: 'F250', paperName: 'Fort 250gsm', gsm: 250, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2300, price2Side: 3500 },
39
+ { id: 15, paperCode: 'F180_355', paperName: 'Fort 180gsm (325x355)', gsm: 180, sheetSize: '325x355', widthMm: 325, heightMm: 355, price1Side: 1400, price2Side: 2200 },
40
+ { id: 16, paperCode: 'F230_355', paperName: 'Fort 230gsm (325x355)', gsm: 230, sheetSize: '325x355', widthMm: 325, heightMm: 355, price1Side: 1500, price2Side: 2300 },
41
+ ];
42
+
43
+ interface CostCalculatorOptions {
44
+ input: CalculationInput;
45
+ paperType: PaperType;
46
+ offsetMachine?: OffsetMachine;
47
+ allOffsetMachines?: OffsetMachine[];
48
+ digitalMachine?: DigitalMachine;
49
+ digitalPricingGiaCong?: DigitalPrintGiaCong[];
50
+ digitalPricingKemGiay?: DigitalPrintKemGiay[];
51
+ allFinishingServices: FinishingService[];
52
+ }
53
+
54
+ export function matchOffsetMachine(
55
+ printSheet: { widthMm: number; heightMm: number },
56
+ allMachines?: OffsetMachine[],
57
+ selectedMachineId?: number
58
+ ): OffsetMachine {
59
+ if (selectedMachineId && allMachines) {
60
+ const found = allMachines.find((m) => m.id === selectedMachineId);
61
+ if (found) return found;
62
+ }
63
+
64
+ const dimMin = Math.min(printSheet.widthMm, printSheet.heightMm);
65
+ const dimMax = Math.max(printSheet.widthMm, printSheet.heightMm);
66
+
67
+ if (allMachines && allMachines.length > 0) {
68
+ // Khổ nhỏ dưới 65x43cm (nếu vừa trong 650 x 430 mm)
69
+ if (dimMin <= 435 && dimMax <= 655) {
70
+ const small = allMachines.find(
71
+ (m) =>
72
+ m.name.toLowerCase().includes('nhỏ') ||
73
+ (m.maxWidthMm <= 655 && m.maxHeightMm <= 435) ||
74
+ (m.maxWidthMm <= 435 && m.maxHeightMm <= 655)
75
+ );
76
+ if (small) return small;
77
+ }
78
+ // Khổ 65x86cm bắt nhíp chiều 86
79
+ const large = allMachines.find(
80
+ (m) => m.name.includes('65x86') || m.maxWidthMm >= 800 || m.maxHeightMm >= 800
81
+ );
82
+ if (large) return large;
83
+ }
84
+
85
+ // Fallback mặc định chuẩn theo yêu cầu xưởng in
86
+ if (dimMin <= 435 && dimMax <= 655) {
87
+ return {
88
+ id: 1,
89
+ name: 'Máy Offset Khổ Nhỏ (dưới 65x43cm)',
90
+ maxWidthMm: 650,
91
+ maxHeightMm: 430,
92
+ minWidthMm: 210,
93
+ minHeightMm: 297,
94
+ platePrice: 75000,
95
+ setupCost: 900000,
96
+ stepCost: 150000,
97
+ defaultWasteSheets: 80,
98
+ gripperMarginMm: 10,
99
+ baseImpressions: 3000,
100
+ includesPlate: true,
101
+ };
102
+ } else {
103
+ return {
104
+ id: 2,
105
+ name: 'Máy Offset Khổ 65x86cm (Bắt nhíp chiều 86)',
106
+ maxWidthMm: 860,
107
+ maxHeightMm: 650,
108
+ minWidthMm: 430,
109
+ minHeightMm: 650,
110
+ platePrice: 100000,
111
+ setupCost: 1200000,
112
+ stepCost: 200000,
113
+ defaultWasteSheets: 100,
114
+ gripperMarginMm: 10,
115
+ baseImpressions: 3000,
116
+ includesPlate: true,
117
+ };
118
+ }
119
+ }
120
+
121
+ export function calculatePrintCost({
122
+ input,
123
+ paperType,
124
+ offsetMachine,
125
+ allOffsetMachines,
126
+ digitalMachine,
127
+ digitalPricingGiaCong = DEFAULT_DIGITAL_GIA_CONG,
128
+ digitalPricingKemGiay = DEFAULT_DIGITAL_KEM_GIAY,
129
+ allFinishingServices,
130
+ }: CostCalculatorOptions): CalculationResult {
131
+ const digitalMode: DigitalMode = input.digitalMode || 'with_paper';
132
+
133
+ // 1. Xác định công nghệ in mục tiêu
134
+ let chosenTech: 'offset' | 'digital' = 'offset';
135
+ if (input.printTech === 'digital') {
136
+ chosenTech = 'digital';
137
+ } else if (input.printTech === 'offset') {
138
+ chosenTech = 'offset';
139
+ } else {
140
+ // Tự động: số lượng < 300 con thường in nhanh rẻ hơn, >= 300 in offset rẻ hơn
141
+ chosenTech = input.quantity < 300 ? 'digital' : 'offset';
142
+ }
143
+
144
+ // 2. Tính bình trang & số con
145
+ const preferredSheet = chosenTech === 'offset' && offsetMachine
146
+ ? { widthMm: offsetMachine.maxWidthMm, heightMm: offsetMachine.maxHeightMm, name: offsetMachine.name }
147
+ : undefined;
148
+
149
+ const imposition = calculateImposition({
150
+ widthMm: input.widthMm,
151
+ heightMm: input.heightMm,
152
+ bleedMm: input.bleedMm ?? 2,
153
+ paperType,
154
+ printTech: chosenTech,
155
+ digitalMode,
156
+ preferredMachineSheet: preferredSheet,
157
+ gripperMarginMm: offsetMachine?.gripperMarginMm ?? 10,
158
+ });
159
+
160
+ const ups = Math.max(1, imposition.upsPerPrintSheet);
161
+ const netPrintSheets = Math.ceil(input.quantity / ups);
162
+
163
+ // 3. Tính bù hao gia công
164
+ let finishingWasteSheets = 0;
165
+ let totalFinishingWastePercent = 0;
166
+ const activeFinishingDetails: FinishingCostDetail[] = [];
167
+
168
+ for (const sel of (input.selectedFinishing || [])) {
169
+ const service = allFinishingServices.find((s) => s.id === sel.serviceId);
170
+ if (!service) continue;
171
+
172
+ totalFinishingWastePercent += service.wastePercent;
173
+ finishingWasteSheets += service.wasteSheets;
174
+
175
+ // Tính chi phí từng mục gia công
176
+ let subtotal = 0;
177
+ let formula = '';
178
+
179
+ if (service.calcType === 'per_m2') {
180
+ const areaM2 = ((input.widthMm + (input.bleedMm || 2) * 2) / 1000) *
181
+ ((input.heightMm + (input.bleedMm || 2) * 2) / 1000) *
182
+ input.quantity * (sel.sides || 1);
183
+ const rawCost = areaM2 * service.unitPrice;
184
+ subtotal = Math.max(service.minPrice, Math.round(rawCost)) + service.setupFee;
185
+ formula = `${areaM2.toFixed(2)} m² × ${service.unitPrice.toLocaleString('vi-VN')}đ (tối thiểu ${service.minPrice.toLocaleString('vi-VN')}đ)`;
186
+ } else if (service.calcType === 'die_cut') {
187
+ const runCost = input.quantity * service.unitPrice;
188
+ subtotal = Math.max(service.minPrice, runCost) + service.setupFee;
189
+ formula = `Khuôn bế ${service.setupFee.toLocaleString('vi-VN')}đ + Công bế (${input.quantity} con × ${service.unitPrice}đ)`;
190
+ } else if (service.calcType === 'per_product') {
191
+ const rawCost = input.quantity * service.unitPrice;
192
+ subtotal = Math.max(service.minPrice, rawCost) + service.setupFee;
193
+ formula = `${input.quantity} con × ${service.unitPrice.toLocaleString('vi-VN')}đ`;
194
+ } else if (service.calcType === 'per_sheet') {
195
+ const rawCost = netPrintSheets * service.unitPrice;
196
+ subtotal = Math.max(service.minPrice, rawCost) + service.setupFee;
197
+ formula = `${netPrintSheets} tờ in × ${service.unitPrice.toLocaleString('vi-VN')}đ`;
198
+ } else {
199
+ // Fixed
200
+ subtotal = service.unitPrice + service.setupFee;
201
+ formula = `Trọn gói ${service.unitPrice.toLocaleString('vi-VN')}đ`;
202
+ }
203
+
204
+ activeFinishingDetails.push({
205
+ serviceId: service.id,
206
+ name: service.name,
207
+ category: service.category,
208
+ calcType: service.calcType,
209
+ quantity: input.quantity,
210
+ unitPrice: service.unitPrice,
211
+ setupFee: service.setupFee,
212
+ subtotal,
213
+ wasteSheets: service.wasteSheets,
214
+ formula,
215
+ });
216
+ }
217
+
218
+ // Bù hao gia công theo %
219
+ finishingWasteSheets += Math.ceil((netPrintSheets * totalFinishingWastePercent) / 100);
220
+
221
+ // 4. Bù hao in ấn & Tổng số tờ in
222
+ let printWasteSheets = 0;
223
+ if (chosenTech === 'offset') {
224
+ printWasteSheets = offsetMachine?.defaultWasteSheets ?? 100;
225
+ } else {
226
+ printWasteSheets = digitalMachine?.defaultWasteSheets ?? 4;
227
+ }
228
+
229
+ const totalPrintSheets = netPrintSheets + printWasteSheets + finishingWasteSheets;
230
+ const cuts = Math.max(1, imposition.cutsPerParentSheet);
231
+ const parentSheetsNeeded = Math.ceil(totalPrintSheets / cuts);
232
+ const ramsNeeded = Math.round((parentSheetsNeeded / 500) * 100) / 100;
233
+
234
+ // Tính kg giấy = Số tờ lớn * Dài (m) * Rộng (m) * (gsm / 1000)
235
+ const parentAreaM2 = (paperType.parentWidthCm / 100) * (paperType.parentHeightCm / 100);
236
+ const kgNeeded = Math.round(((parentSheetsNeeded * parentAreaM2 * paperType.gsm) / 1000) * 10) / 10;
237
+
238
+ // 5. Tiền giấy: Bỏ giá /kg, chỉ áp dụng giá trên 500 tờ (trên ram) và giá dưới 500 tờ (dưới ram)
239
+ const priceAbove500 = paperType.priceAbove500 || paperType.pricePerRam;
240
+ const priceBelow500 = paperType.priceBelow500 || priceAbove500;
241
+
242
+ // Nếu số tờ mẹ >= 500 (trên 1 ram) áp dụng giá sỉ nguyên ram, nếu < 500 tờ áp dụng giá lẻ dưới ram
243
+ const isWholesale = parentSheetsNeeded >= 500;
244
+ const effectiveRamPrice = isWholesale ? priceAbove500 : priceBelow500;
245
+ let paperCost = Math.round(parentSheetsNeeded * (effectiveRamPrice / 500));
246
+
247
+ // 6. Tiền in ấn
248
+ let plateCost = 0;
249
+ let platesCount = 0;
250
+ let printRunCost = 0;
251
+ let shortRunFee = 0;
252
+ let digitalDetails: CalculationResult['costs']['digitalDetails'] | undefined;
253
+
254
+ if (chosenTech === 'offset') {
255
+ const machine = offsetMachine || matchOffsetMachine(imposition.printSheet, allOffsetMachines, input.offsetMachineId);
256
+
257
+ const is2Sides = input.printSides === '2_side';
258
+ const workType = input.offsetWorkType || 'self_turn';
259
+ const baseImpressions = machine.baseImpressions || 3000;
260
+ const includesPlate = machine.includesPlate ?? true;
261
+
262
+ if (!is2Sides) {
263
+ // In 1 mặt: 1 ca máy, 1 bộ kẽm (chuẩn 4 màu)
264
+ const colors = input.colorsFront || 4;
265
+ platesCount = colors;
266
+
267
+ if (includesPlate) {
268
+ // Gói mở máy đã bao gồm kẽm 4 màu, chỉ tính thêm tiền kẽm nếu khách in màu thứ 5+
269
+ const extraColors = Math.max(0, colors - 4);
270
+ plateCost = extraColors * machine.platePrice;
271
+ } else {
272
+ plateCost = platesCount * machine.platePrice;
273
+ }
274
+
275
+ const impressions = totalPrintSheets;
276
+ if (impressions <= baseImpressions) {
277
+ printRunCost = machine.setupCost;
278
+ } else {
279
+ const extraThousands = Math.ceil((impressions - baseImpressions) / 1000);
280
+ printRunCost = machine.setupCost + extraThousands * machine.stepCost;
281
+ }
282
+ } else {
283
+ // In 2 mặt
284
+ if (workType === 'sheetwise') {
285
+ // In 2 bài riêng: 2 ca in độc lập, 2 bộ kẽm
286
+ const colors = (input.colorsFront || 4) + (input.colorsBack || 4);
287
+ platesCount = colors;
288
+
289
+ if (includesPlate) {
290
+ // Gói mở máy đã bao gồm 1 bộ kẽm 4 màu cơ bản, bài thứ 2 hoặc màu thứ 5+ tính riêng kẽm
291
+ const extraColors = Math.max(0, colors - 4);
292
+ plateCost = extraColors * machine.platePrice;
293
+ } else {
294
+ plateCost = platesCount * machine.platePrice;
295
+ }
296
+
297
+ // 2 ca in độc lập
298
+ const impressionsPerSide = totalPrintSheets;
299
+ const costSide1 = impressionsPerSide <= baseImpressions
300
+ ? machine.setupCost
301
+ : machine.setupCost + Math.ceil((impressionsPerSide - baseImpressions) / 1000) * machine.stepCost;
302
+ const costSide2 = impressionsPerSide <= baseImpressions
303
+ ? machine.setupCost
304
+ : machine.setupCost + Math.ceil((impressionsPerSide - baseImpressions) / 1000) * machine.stepCost;
305
+ printRunCost = costSide1 + costSide2;
306
+ } else {
307
+ // Tự trở (Work & Turn) hoặc Trở nhíp (Tumble): Dùng chung 1 bộ kẽm ghép cả 2 mặt (1 ca máy)
308
+ const colors = Math.max(input.colorsFront || 4, input.colorsBack || 4);
309
+ platesCount = colors;
310
+
311
+ if (includesPlate) {
312
+ const extraColors = Math.max(0, colors - 4);
313
+ plateCost = extraColors * machine.platePrice;
314
+ } else {
315
+ plateCost = platesCount * machine.platePrice;
316
+ }
317
+
318
+ // Chạy 2 lượt in (lượt ép x 2)
319
+ const totalImpressions = totalPrintSheets * 2;
320
+ if (totalImpressions <= baseImpressions) {
321
+ printRunCost = machine.setupCost;
322
+ } else {
323
+ const extraThousands = Math.ceil((totalImpressions - baseImpressions) / 1000);
324
+ printRunCost = machine.setupCost + extraThousands * machine.stepCost;
325
+ }
326
+ }
327
+ }
328
+ } else {
329
+ // In nhanh kỹ thuật số (Digital)
330
+ const is2Sides = input.printSides === '2_side';
331
+ let ratePerSheet = 0;
332
+ let machineName = '';
333
+ let sheetSizeName = imposition.printSheet.name;
334
+ let whiteInkCost = 0;
335
+
336
+ // Phụ phí số lượng ít:
337
+ // < 50 tờ: +30.000đ
338
+ // < 100 tờ: +20.000đ
339
+ // >= 100 tờ: 0đ
340
+ if (totalPrintSheets < 50) {
341
+ shortRunFee = 30000;
342
+ } else if (totalPrintSheets < 100) {
343
+ shortRunFee = 20000;
344
+ } else {
345
+ shortRunFee = 0;
346
+ }
347
+
348
+ if (digitalMode === 'with_paper') {
349
+ // 1. In nhanh kèm giấy (INTC) - Đã gồm tiền giấy trọn gói
350
+ machineName = 'In Nhanh Kèm Giấy (INTC)';
351
+ const kemGiayList = digitalPricingKemGiay && digitalPricingKemGiay.length > 0
352
+ ? digitalPricingKemGiay
353
+ : DEFAULT_DIGITAL_KEM_GIAY;
354
+
355
+ // Tìm dòng giá phù hợp theo mã giấy
356
+ let matchedPricing = kemGiayList.find((kg) =>
357
+ paperType.code.toUpperCase().includes(kg.paperCode.toUpperCase())
358
+ );
359
+
360
+ // Nếu không khớp chính xác mã, so khớp theo tiền tố chất liệu (C, I, F) + GSM gần nhất
361
+ if (!matchedPricing) {
362
+ const pName = paperType.name.toLowerCase();
363
+ let prefix = 'C';
364
+ if (pName.includes('ivory') || paperType.code.toUpperCase().startsWith('I')) prefix = 'I';
365
+ else if (pName.includes('fort') || pName.includes('ford') || paperType.code.toUpperCase().startsWith('F')) prefix = 'F';
366
+
367
+ const sameType = kemGiayList.filter((kg) => kg.paperCode.startsWith(prefix));
368
+ if (sameType.length > 0) {
369
+ matchedPricing = sameType.reduce((prev, curr) =>
370
+ Math.abs(curr.gsm - paperType.gsm) < Math.abs(prev.gsm - paperType.gsm) ? curr : prev
371
+ );
372
+ } else {
373
+ matchedPricing = kemGiayList[0];
374
+ }
375
+ }
376
+
377
+ if (matchedPricing) {
378
+ ratePerSheet = is2Sides ? matchedPricing.price2Side : matchedPricing.price1Side;
379
+ sheetSizeName = `${matchedPricing.sheetSize} mm (${matchedPricing.paperName})`;
380
+ } else {
381
+ ratePerSheet = is2Sides ? 3000 : 1800;
382
+ }
383
+
384
+ printRunCost = Math.round(totalPrintSheets * ratePerSheet) + shortRunFee;
385
+
386
+ // Đã kèm giấy nên tiền giấy = 0
387
+ paperCost = 0;
388
+ plateCost = 0;
389
+ platesCount = 0;
390
+
391
+ digitalDetails = {
392
+ mode: 'with_paper',
393
+ machineName,
394
+ sheetSizeName,
395
+ ratePerSheet,
396
+ shortRunFee,
397
+ note: `Bảng giá In Nhanh Kèm Giấy INTC (Đã gồm công in + giấy ${matchedPricing?.paperName || ''}). Phí SL ít: +${shortRunFee.toLocaleString('vi-VN')}đ`,
398
+ };
399
+ } else {
400
+ // 2. In nhanh gia công (Konica C12000 / C12010S 5 màu - Trang 2 PDF)
401
+ machineName = 'Konica C12000 / C12010S 5 Màu';
402
+ const giaCongList = digitalPricingGiaCong && digitalPricingGiaCong.length > 0
403
+ ? digitalPricingGiaCong
404
+ : DEFAULT_DIGITAL_GIA_CONG;
405
+
406
+ // Nhận diện khổ in máy
407
+ const pW = imposition.printSheet.widthMm;
408
+ const pH = imposition.printSheet.heightMm;
409
+ const maxDim = Math.max(pW, pH);
410
+ const minDim = Math.min(pW, pH);
411
+
412
+ let sheetRow: DigitalPrintGiaCong | undefined;
413
+ if (maxDim > 500) {
414
+ sheetRow = giaCongList.find((g) => g.sheetSize === '330x1200');
415
+ } else if (maxDim > 430 || minDim > 300) {
416
+ sheetRow = giaCongList.find((g) => g.sheetSize === '330x483');
417
+ } else if (maxDim > 360 || minDim > 250) {
418
+ sheetRow = giaCongList.find((g) => g.sheetSize === 'A3');
419
+ } else if (maxDim > 300) {
420
+ sheetRow = giaCongList.find((g) => g.sheetSize === '330x355');
421
+ } else {
422
+ sheetRow = giaCongList.find((g) => g.sheetSize === 'A4');
423
+ }
424
+
425
+ if (!sheetRow) {
426
+ sheetRow = giaCongList.find((g) => g.sheetSize === '330x483') || giaCongList[0];
427
+ }
428
+
429
+ // Nhận diện nhóm chất liệu
430
+ const pName = paperType.name.toLowerCase();
431
+ let click1Side = 0;
432
+
433
+ if (sheetRow) {
434
+ if (pName.includes('pvc')) {
435
+ click1Side = sheetRow.pvcPlastic || sheetRow.paper350To450;
436
+ } else if (pName.includes('nhựa') || pName.includes('synthetic')) {
437
+ click1Side = sheetRow.syntheticPaper;
438
+ } else if (pName.includes('trong') && pName.includes('decal')) {
439
+ click1Side = sheetRow.decalClear;
440
+ } else if (pName.includes('decal')) {
441
+ click1Side = sheetRow.decalPaperPlastic;
442
+ } else {
443
+ // Giấy thường theo định lượng GSM
444
+ if (paperType.gsm < 250) {
445
+ click1Side = sheetRow.paperLt249;
446
+ } else if (paperType.gsm <= 349) {
447
+ click1Side = sheetRow.paper250To349;
448
+ } else {
449
+ click1Side = sheetRow.paper350To450;
450
+ }
451
+ }
452
+ sheetSizeName = `${sheetRow.sheetSize} (${sheetRow.widthMm}x${sheetRow.heightMm}mm)`;
453
+ } else {
454
+ click1Side = is2Sides ? 2000 : 1000;
455
+ }
456
+
457
+ // Giá in 2 mặt = x2 giá 1 mặt
458
+ ratePerSheet = is2Sides ? (click1Side * 2) : click1Side;
459
+
460
+ // In mực trắng nếu có tùy chọn
461
+ if (input.whiteInk) {
462
+ whiteInkCost = totalPrintSheets * (is2Sides ? 4000 : 2000);
463
+ }
464
+
465
+ printRunCost = Math.round(totalPrintSheets * ratePerSheet) + whiteInkCost + shortRunFee;
466
+
467
+ // Nếu khách tự cấp giấy -> tiền giấy = 0
468
+ if (input.customerSuppliedPaper) {
469
+ paperCost = 0;
470
+ }
471
+
472
+ plateCost = 0;
473
+ platesCount = 0;
474
+
475
+ digitalDetails = {
476
+ mode: 'without_paper',
477
+ machineName,
478
+ sheetSizeName,
479
+ ratePerSheet,
480
+ shortRunFee,
481
+ whiteInkCost,
482
+ note: `In Gia Công Konica C12000 (Khổ ${sheetSizeName}, Đơn giá ${ratePerSheet.toLocaleString('vi-VN')}đ/tờ). Phí SL ít: +${shortRunFee.toLocaleString('vi-VN')}đ${input.customerSuppliedPaper ? ' • Khách cấp giấy' : ''}`,
483
+ };
484
+ }
485
+ }
486
+
487
+ const totalPrintCost = plateCost + printRunCost;
488
+ const totalFinishingCost = activeFinishingDetails.reduce((sum, item) => sum + item.subtotal, 0);
489
+
490
+ // 7. Tổng chi phí cơ sở & Giá bán
491
+ const totalBaseCost = paperCost + totalPrintCost + totalFinishingCost;
492
+ const profitMarginPercent = input.profitMarginPercent ?? 25;
493
+ const profitAmount = Math.round((totalBaseCost * profitMarginPercent) / 100);
494
+ const discountAmount = input.discountAmount ?? 0;
495
+ const preTaxTotal = Math.max(0, totalBaseCost + profitAmount - discountAmount);
496
+ const vatPercent = input.vatPercent ?? 0;
497
+ const vatAmount = Math.round((preTaxTotal * vatPercent) / 100);
498
+ const finalPrice = preTaxTotal + vatAmount;
499
+ const unitPrice = input.quantity > 0 ? Math.round(finalPrice / input.quantity) : 0;
500
+
501
+ // 8. Thuật toán So sánh In Nhanh vs In Offset & Tìm điểm hòa vốn
502
+ const comparisonTable: ComparisonItem[] = [];
503
+ const testQuantities = [50, 100, 200, 300, 500, 1000, 2000, 5000];
504
+
505
+ for (const qty of testQuantities) {
506
+ // Tính thử chi phí digital
507
+ const digResult = runQuickEstimate({
508
+ ...input,
509
+ quantity: qty,
510
+ printTech: 'digital',
511
+ }, paperType, offsetMachine, digitalMachine, allFinishingServices, digitalPricingGiaCong, digitalPricingKemGiay);
512
+
513
+ // Tính thử chi phí offset
514
+ const offResult = runQuickEstimate({
515
+ ...input,
516
+ quantity: qty,
517
+ printTech: 'offset',
518
+ }, paperType, offsetMachine, digitalMachine, allFinishingServices, digitalPricingGiaCong, digitalPricingKemGiay);
519
+
520
+ comparisonTable.push({
521
+ quantity: qty,
522
+ digitalTotal: digResult.finalPrice,
523
+ digitalUnit: digResult.unitPrice,
524
+ offsetTotal: offResult.finalPrice,
525
+ offsetUnit: offResult.unitPrice,
526
+ recommended: digResult.finalPrice <= offResult.finalPrice ? 'digital' : 'offset',
527
+ });
528
+ }
529
+
530
+ // Điểm hòa vốn (Break-even): Tìm mốc số lượng mà offset bắt đầu rẻ hơn digital
531
+ let breakEvenQty = 300;
532
+ for (let i = 0; i < comparisonTable.length - 1; i++) {
533
+ if (comparisonTable[i].recommended === 'digital' && comparisonTable[i + 1].recommended === 'offset') {
534
+ breakEvenQty = Math.round((comparisonTable[i].quantity + comparisonTable[i + 1].quantity) / 2);
535
+ break;
536
+ }
537
+ }
538
+
539
+ // Tính lại cho số lượng hiện tại nếu người dùng chọn 'auto'
540
+ const curDigital = runQuickEstimate({ ...input, printTech: 'digital' }, paperType, offsetMachine, digitalMachine, allFinishingServices, digitalPricingGiaCong, digitalPricingKemGiay);
541
+ const curOffset = runQuickEstimate({ ...input, printTech: 'offset' }, paperType, offsetMachine, digitalMachine, allFinishingServices, digitalPricingGiaCong, digitalPricingKemGiay);
542
+
543
+ let recommendationText = '';
544
+ const diff = Math.abs(curDigital.finalPrice - curOffset.finalPrice);
545
+
546
+ if (curDigital.finalPrice < curOffset.finalPrice) {
547
+ recommendationText = `Với số lượng ${input.quantity.toLocaleString('vi-VN')} sản phẩm, In Nhanh KTS tiết kiệm hơn In Offset ${diff.toLocaleString('vi-VN')}đ (${Math.round((diff / curOffset.finalPrice) * 100)}%), không tốn phí mở máy và kẽm CTP, lấy nhanh trong ngày.`;
548
+ } else {
549
+ recommendationText = `Với số lượng ${input.quantity.toLocaleString('vi-VN')} sản phẩm, In Offset tối ưu chi phí hơn In Nhanh ${diff.toLocaleString('vi-VN')}đ (${Math.round((diff / curDigital.finalPrice) * 100)}%), giá đơn vị giảm sâu theo số lượng lớn.`;
550
+ }
551
+
552
+ return {
553
+ imposition,
554
+ quantities: {
555
+ productQty: input.quantity,
556
+ netPrintSheets,
557
+ printWasteSheets,
558
+ finishingWasteSheets,
559
+ totalPrintSheets,
560
+ parentSheetsNeeded,
561
+ ramsNeeded,
562
+ kgNeeded,
563
+ },
564
+ costs: {
565
+ paperCost,
566
+ plateCost,
567
+ platesCount,
568
+ printRunCost,
569
+ totalPrintCost,
570
+ finishingCost: totalFinishingCost,
571
+ finishingDetails: activeFinishingDetails,
572
+ totalBaseCost,
573
+ profitAmount,
574
+ preTaxTotal,
575
+ discountAmount,
576
+ vatAmount,
577
+ finalPrice,
578
+ unitPrice,
579
+ shortRunFee,
580
+ digitalDetails,
581
+ },
582
+ chosenTech,
583
+ recommendation: {
584
+ bestTech: curDigital.finalPrice <= curOffset.finalPrice ? 'digital' : 'offset',
585
+ reason: recommendationText,
586
+ digitalTotal: curDigital.finalPrice,
587
+ offsetTotal: curOffset.finalPrice,
588
+ difference: diff,
589
+ breakEvenQty,
590
+ comparisonTable,
591
+ },
592
+ };
593
+ }
594
+
595
+ // Hàm tính nhanh phục vụ bảng so sánh (tránh đệ quy)
596
+ function runQuickEstimate(
597
+ input: CalculationInput,
598
+ paperType: PaperType,
599
+ offsetMachine?: OffsetMachine,
600
+ digitalMachine?: DigitalMachine,
601
+ allFinishingServices: FinishingService[] = [],
602
+ digitalPricingGiaCong: DigitalPrintGiaCong[] = DEFAULT_DIGITAL_GIA_CONG,
603
+ digitalPricingKemGiay: DigitalPrintKemGiay[] = DEFAULT_DIGITAL_KEM_GIAY
604
+ ): { finalPrice: number; unitPrice: number } {
605
+ const isDigital = input.printTech === 'digital';
606
+ const digitalMode: DigitalMode = input.digitalMode || 'with_paper';
607
+ const preferredSheet = !isDigital && offsetMachine
608
+ ? { widthMm: offsetMachine.maxWidthMm, heightMm: offsetMachine.maxHeightMm, name: offsetMachine.name }
609
+ : undefined;
610
+
611
+ const imposition = calculateImposition({
612
+ widthMm: input.widthMm,
613
+ heightMm: input.heightMm,
614
+ bleedMm: input.bleedMm ?? 2,
615
+ paperType,
616
+ printTech: input.printTech,
617
+ digitalMode,
618
+ preferredMachineSheet: preferredSheet,
619
+ });
620
+
621
+ const ups = Math.max(1, imposition.upsPerPrintSheet);
622
+ const netSheets = Math.ceil(input.quantity / ups);
623
+ const printWaste = isDigital ? 4 : (offsetMachine?.defaultWasteSheets ?? 100);
624
+ const totalSheets = netSheets + printWaste + 10;
625
+
626
+ const cuts = Math.max(1, imposition.cutsPerParentSheet);
627
+ const parentSheets = Math.ceil(totalSheets / cuts);
628
+ const pAbove = paperType.priceAbove500 || paperType.pricePerRam;
629
+ const pBelow = paperType.priceBelow500 || pAbove;
630
+ const effectiveRamPrice = parentSheets >= 500 ? pAbove : pBelow;
631
+ let paperCost = Math.round(parentSheets * (effectiveRamPrice / 500));
632
+
633
+ let printCost = 0;
634
+ if (isDigital) {
635
+ const is2Sides = input.printSides === '2_side';
636
+ let shortFee = 0;
637
+ if (totalSheets < 50) shortFee = 30000;
638
+ else if (totalSheets < 100) shortFee = 20000;
639
+
640
+ if (digitalMode === 'with_paper') {
641
+ const matched = digitalPricingKemGiay.find((kg) =>
642
+ paperType.code.toUpperCase().includes(kg.paperCode.toUpperCase())
643
+ ) || digitalPricingKemGiay[0];
644
+ const rate = is2Sides ? matched.price2Side : matched.price1Side;
645
+ printCost = Math.round(totalSheets * rate) + shortFee;
646
+ paperCost = 0;
647
+ } else {
648
+ const isA3 = imposition.printSheet.widthMm > 220 || imposition.printSheet.heightMm > 310;
649
+ const rate = isA3 ? (is2Sides ? 4000 : 2000) : (is2Sides ? 1600 : 800);
650
+ printCost = Math.round(totalSheets * rate) + shortFee;
651
+ if (input.customerSuppliedPaper) paperCost = 0;
652
+ }
653
+ } else {
654
+ const machine = offsetMachine || matchOffsetMachine(imposition.printSheet);
655
+ const baseImpressions = machine.baseImpressions || 3000;
656
+ const includesPlate = machine.includesPlate ?? true;
657
+ const platePrice = includesPlate ? 0 : 4 * machine.platePrice;
658
+ const impressions = input.printSides === '2_side' ? totalSheets * 2 : totalSheets;
659
+ const runPrice = impressions <= baseImpressions
660
+ ? machine.setupCost
661
+ : machine.setupCost + Math.ceil((impressions - baseImpressions) / 1000) * machine.stepCost;
662
+ printCost = platePrice + runPrice;
663
+ }
664
+
665
+ // Ước tính gia công cơ bản
666
+ let finishingCost = 0;
667
+ for (const sel of (input.selectedFinishing || [])) {
668
+ const service = allFinishingServices.find((s) => s.id === sel.serviceId);
669
+ if (service) {
670
+ if (service.calcType === 'per_m2') {
671
+ const area = ((input.widthMm + 4) / 1000) * ((input.heightMm + 4) / 1000) * input.quantity * (sel.sides || 1);
672
+ finishingCost += Math.max(service.minPrice, Math.round(area * service.unitPrice)) + service.setupFee;
673
+ } else {
674
+ finishingCost += Math.max(service.minPrice, input.quantity * service.unitPrice) + service.setupFee;
675
+ }
676
+ }
677
+ }
678
+
679
+ const baseCost = paperCost + printCost + finishingCost;
680
+ const margin = input.profitMarginPercent ?? 25;
681
+ const finalPrice = Math.round(baseCost * (1 + margin / 100));
682
+ const unitPrice = input.quantity > 0 ? Math.round(finalPrice / input.quantity) : 0;
683
+
684
+ return { finalPrice, unitPrice };
685
+ }
686
+ """
687
+
688
+ with open("src/lib/engine/costCalculator.ts", "w", encoding="utf-8") as f:
689
+ f.write(new_content)
690
+ print("Updated costCalculator.ts successfully!")
src/app/api/calculate/route.ts CHANGED
@@ -1,7 +1,15 @@
1
  import { NextRequest, NextResponse } from 'next/server';
2
  import { getDb } from '@/lib/db';
3
  import { calculatePrintCost } from '@/lib/engine/costCalculator';
4
- import { CalculationInput, DigitalMachine, FinishingService, OffsetMachine, PaperType } from '@/types';
 
 
 
 
 
 
 
 
5
 
6
  export async function POST(req: NextRequest) {
7
  try {
@@ -13,7 +21,8 @@ export async function POST(req: NextRequest) {
13
  if (input.paperTypeId) {
14
  paperRow = db.prepare('SELECT * FROM paper_types WHERE id = ?').get(input.paperTypeId) as Record<string, unknown> | undefined;
15
  } else if ((input as unknown as Record<string, unknown>).paperCode) {
16
- paperRow = db.prepare('SELECT * FROM paper_types WHERE code = ?').get((input as unknown as Record<string, unknown>).paperCode as string) as Record<string, unknown> | undefined;
 
17
  } else {
18
  paperRow = db.prepare('SELECT * FROM paper_types WHERE is_active = 1 LIMIT 1').get() as Record<string, unknown> | undefined;
19
  }
@@ -63,6 +72,37 @@ export async function POST(req: NextRequest) {
63
  if (row) digitalMachine = mapDigitalMachine(row);
64
  }
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  // Lấy danh mục dịch vụ gia công
67
  const finishingRows = db.prepare('SELECT * FROM finishing_services WHERE is_active = 1').all() as Record<string, unknown>[];
68
  const allFinishingServices: FinishingService[] = finishingRows.map((r) => ({
@@ -115,6 +155,9 @@ export async function POST(req: NextRequest) {
115
  offsetWorkType: (rawBody.offsetWorkType as CalculationInput['offsetWorkType']) || 'self_turn',
116
  offsetMachineId: rawBody.offsetMachineId ? Number(rawBody.offsetMachineId) : undefined,
117
  digitalMachineId: rawBody.digitalMachineId ? Number(rawBody.digitalMachineId) : undefined,
 
 
 
118
  selectedFinishing: sanitizedFinishing,
119
  profitMarginPercent: Number(rawBody.profitMarginPercent) || 0,
120
  discountAmount: Number(rawBody.discountAmount) || 0,
@@ -128,6 +171,8 @@ export async function POST(req: NextRequest) {
128
  allOffsetMachines,
129
  digitalMachine,
130
  allFinishingServices,
 
 
131
  });
132
 
133
  return NextResponse.json(result);
 
1
  import { NextRequest, NextResponse } from 'next/server';
2
  import { getDb } from '@/lib/db';
3
  import { calculatePrintCost } from '@/lib/engine/costCalculator';
4
+ import {
5
+ CalculationInput,
6
+ DigitalMachine,
7
+ DigitalPrintGiaCong,
8
+ DigitalPrintKemGiay,
9
+ FinishingService,
10
+ OffsetMachine,
11
+ PaperType,
12
+ } from '@/types';
13
 
14
  export async function POST(req: NextRequest) {
15
  try {
 
21
  if (input.paperTypeId) {
22
  paperRow = db.prepare('SELECT * FROM paper_types WHERE id = ?').get(input.paperTypeId) as Record<string, unknown> | undefined;
23
  } else if ((input as unknown as Record<string, unknown>).paperCode) {
24
+ const codeStr = (input as unknown as Record<string, unknown>).paperCode as string;
25
+ paperRow = db.prepare('SELECT * FROM paper_types WHERE code = ? OR code LIKE ? LIMIT 1').get(codeStr, `${codeStr}%`) as Record<string, unknown> | undefined;
26
  } else {
27
  paperRow = db.prepare('SELECT * FROM paper_types WHERE is_active = 1 LIMIT 1').get() as Record<string, unknown> | undefined;
28
  }
 
72
  if (row) digitalMachine = mapDigitalMachine(row);
73
  }
74
 
75
+ // Lấy bảng giá in nhanh gia công (Konica C12000)
76
+ const digitalGiaCongRows = db.prepare('SELECT * FROM digital_pricing_giacong ORDER BY id ASC').all() as Record<string, unknown>[];
77
+ const digitalPricingGiaCong: DigitalPrintGiaCong[] = digitalGiaCongRows.map((r) => ({
78
+ id: r.id as number,
79
+ machineName: r.machine_name as string,
80
+ sheetSize: r.sheet_size as string,
81
+ widthMm: r.width_mm as number,
82
+ heightMm: r.height_mm as number,
83
+ paperLt249: r.paper_lt_249 as number,
84
+ paper250To349: r.paper_250_349 as number,
85
+ paper350To450: r.paper_350_450 as number,
86
+ decalPaperPlastic: r.decal_paper_plastic as number,
87
+ decalClear: r.decal_clear as number,
88
+ syntheticPaper: r.synthetic_paper as number,
89
+ pvcPlastic: r.pvc_plastic as number,
90
+ }));
91
+
92
+ // Lấy bảng giá in nhanh kèm giấy (INTC)
93
+ const digitalKemGiayRows = db.prepare('SELECT * FROM digital_pricing_kem_giay ORDER BY id ASC').all() as Record<string, unknown>[];
94
+ const digitalPricingKemGiay: DigitalPrintKemGiay[] = digitalKemGiayRows.map((r) => ({
95
+ id: r.id as number,
96
+ paperCode: r.paper_code as string,
97
+ paperName: r.paper_name as string,
98
+ gsm: r.gsm as number,
99
+ sheetSize: r.sheet_size as string,
100
+ widthMm: r.width_mm as number,
101
+ heightMm: r.height_mm as number,
102
+ price1Side: r.price_1side as number,
103
+ price2Side: r.price_2side as number,
104
+ }));
105
+
106
  // Lấy danh mục dịch vụ gia công
107
  const finishingRows = db.prepare('SELECT * FROM finishing_services WHERE is_active = 1').all() as Record<string, unknown>[];
108
  const allFinishingServices: FinishingService[] = finishingRows.map((r) => ({
 
155
  offsetWorkType: (rawBody.offsetWorkType as CalculationInput['offsetWorkType']) || 'self_turn',
156
  offsetMachineId: rawBody.offsetMachineId ? Number(rawBody.offsetMachineId) : undefined,
157
  digitalMachineId: rawBody.digitalMachineId ? Number(rawBody.digitalMachineId) : undefined,
158
+ digitalMode: (rawBody.digitalMode as CalculationInput['digitalMode']) || 'with_paper',
159
+ customerSuppliedPaper: Boolean(rawBody.customerSuppliedPaper),
160
+ whiteInk: Boolean(rawBody.whiteInk),
161
  selectedFinishing: sanitizedFinishing,
162
  profitMarginPercent: Number(rawBody.profitMarginPercent) || 0,
163
  discountAmount: Number(rawBody.discountAmount) || 0,
 
171
  allOffsetMachines,
172
  digitalMachine,
173
  allFinishingServices,
174
+ digitalPricingGiaCong,
175
+ digitalPricingKemGiay,
176
  });
177
 
178
  return NextResponse.json(result);
src/app/api/machines/route.ts CHANGED
@@ -37,7 +37,42 @@ export async function GET() {
37
  minCharge: r.min_charge as number,
38
  }));
39
 
40
- return NextResponse.json({ offsetMachines, digitalMachines });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  } catch (err: unknown) {
42
  return NextResponse.json({ error: (err as Error).message }, { status: 500 });
43
  }
@@ -85,6 +120,33 @@ export async function PUT(req: NextRequest) {
85
  body.minCharge,
86
  body.id
87
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  }
89
 
90
  return NextResponse.json({ success: true });
 
37
  minCharge: r.min_charge as number,
38
  }));
39
 
40
+ const digitalGiaCongRows = db.prepare('SELECT * FROM digital_pricing_giacong ORDER BY id ASC').all() as Record<string, unknown>[];
41
+ const digitalKemGiayRows = db.prepare('SELECT * FROM digital_pricing_kem_giay ORDER BY id ASC').all() as Record<string, unknown>[];
42
+
43
+ const digitalPricingGiaCong = digitalGiaCongRows.map((r) => ({
44
+ id: r.id as number,
45
+ machineName: r.machine_name as string,
46
+ sheetSize: r.sheet_size as string,
47
+ widthMm: r.width_mm as number,
48
+ heightMm: r.height_mm as number,
49
+ paperLt249: r.paper_lt_249 as number,
50
+ paper250To349: r.paper_250_349 as number,
51
+ paper350To450: r.paper_350_450 as number,
52
+ decalPaperPlastic: r.decal_paper_plastic as number,
53
+ decalClear: r.decal_clear as number,
54
+ syntheticPaper: r.synthetic_paper as number,
55
+ pvcPlastic: r.pvc_plastic as number,
56
+ }));
57
+
58
+ const digitalPricingKemGiay = digitalKemGiayRows.map((r) => ({
59
+ id: r.id as number,
60
+ paperCode: r.paper_code as string,
61
+ paperName: r.paper_name as string,
62
+ gsm: r.gsm as number,
63
+ sheetSize: r.sheet_size as string,
64
+ widthMm: r.width_mm as number,
65
+ heightMm: r.height_mm as number,
66
+ price1Side: r.price_1side as number,
67
+ price2Side: r.price_2side as number,
68
+ }));
69
+
70
+ return NextResponse.json({
71
+ offsetMachines,
72
+ digitalMachines,
73
+ digitalPricingGiaCong,
74
+ digitalPricingKemGiay,
75
+ });
76
  } catch (err: unknown) {
77
  return NextResponse.json({ error: (err as Error).message }, { status: 500 });
78
  }
 
120
  body.minCharge,
121
  body.id
122
  );
123
+ } else if (body.type === 'digital_giacong') {
124
+ const stmt = db.prepare(`
125
+ UPDATE digital_pricing_giacong
126
+ SET paper_lt_249 = ?, paper_250_349 = ?, paper_350_450 = ?, decal_paper_plastic = ?, decal_clear = ?, synthetic_paper = ?, pvc_plastic = ?, updated_at = CURRENT_TIMESTAMP
127
+ WHERE id = ?
128
+ `);
129
+ stmt.run(
130
+ body.paperLt249,
131
+ body.paper250To349,
132
+ body.paper350To450,
133
+ body.decalPaperPlastic,
134
+ body.decalClear,
135
+ body.syntheticPaper,
136
+ body.pvcPlastic,
137
+ body.id
138
+ );
139
+ } else if (body.type === 'digital_kem_giay') {
140
+ const stmt = db.prepare(`
141
+ UPDATE digital_pricing_kem_giay
142
+ SET price_1side = ?, price_2side = ?, updated_at = CURRENT_TIMESTAMP
143
+ WHERE id = ?
144
+ `);
145
+ stmt.run(
146
+ body.price1Side,
147
+ body.price2Side,
148
+ body.id
149
+ );
150
  }
151
 
152
  return NextResponse.json({ success: true });
src/app/page.tsx CHANGED
@@ -17,6 +17,7 @@ import {
17
  CalculationInput,
18
  CalculationResult,
19
  DigitalMachine,
 
20
  FinishingService,
21
  OffsetMachine,
22
  PaperType,
@@ -55,6 +56,9 @@ export default function HomePage() {
55
  const [jobName, setJobName] = useState('In Tờ Rơi A4');
56
  const [productType, setProductType] = useState<ProductType>('to_roi');
57
  const [printTech, setPrintTech] = useState<PrintTech>('auto');
 
 
 
58
  const [quantity, setQuantity] = useState(1000);
59
  const [widthMm, setWidthMm] = useState(210);
60
  const [heightMm, setHeightMm] = useState(297);
@@ -143,6 +147,9 @@ export default function HomePage() {
143
  colorsFront: Number(colorsFront) || 4,
144
  colorsBack: printSides === '2_side' ? (Number(colorsBack) || 4) : 0,
145
  offsetWorkType,
 
 
 
146
  selectedFinishing,
147
  profitMarginPercent: Number(profitMarginPercent) || 0,
148
  discountAmount: Number(discountAmount) || 0,
@@ -168,6 +175,9 @@ export default function HomePage() {
168
  jobName,
169
  productType,
170
  printTech,
 
 
 
171
  quantity,
172
  widthMm,
173
  heightMm,
@@ -591,6 +601,74 @@ export default function HomePage() {
591
  </div>
592
  </div>
593
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
594
  </div>
595
  </div>
596
 
 
17
  CalculationInput,
18
  CalculationResult,
19
  DigitalMachine,
20
+ DigitalMode,
21
  FinishingService,
22
  OffsetMachine,
23
  PaperType,
 
56
  const [jobName, setJobName] = useState('In Tờ Rơi A4');
57
  const [productType, setProductType] = useState<ProductType>('to_roi');
58
  const [printTech, setPrintTech] = useState<PrintTech>('auto');
59
+ const [digitalMode, setDigitalMode] = useState<DigitalMode>('with_paper');
60
+ const [customerSuppliedPaper, setCustomerSuppliedPaper] = useState(false);
61
+ const [whiteInk, setWhiteInk] = useState(false);
62
  const [quantity, setQuantity] = useState(1000);
63
  const [widthMm, setWidthMm] = useState(210);
64
  const [heightMm, setHeightMm] = useState(297);
 
147
  colorsFront: Number(colorsFront) || 4,
148
  colorsBack: printSides === '2_side' ? (Number(colorsBack) || 4) : 0,
149
  offsetWorkType,
150
+ digitalMode,
151
+ customerSuppliedPaper,
152
+ whiteInk,
153
  selectedFinishing,
154
  profitMarginPercent: Number(profitMarginPercent) || 0,
155
  discountAmount: Number(discountAmount) || 0,
 
175
  jobName,
176
  productType,
177
  printTech,
178
+ digitalMode,
179
+ customerSuppliedPaper,
180
+ whiteInk,
181
  quantity,
182
  widthMm,
183
  heightMm,
 
601
  </div>
602
  </div>
603
  </div>
604
+
605
+ {/* Tùy chọn in nhanh KTS (Kèm giấy INTC vs Gia công Konica C12000) */}
606
+ {(printTech === 'digital' || printTech === 'auto') && (
607
+ <div className="mt-3 p-3 bg-sky-50/70 border border-sky-200/80 rounded-xl space-y-2.5">
608
+ <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-1.5">
609
+ <span className="text-xs font-bold text-sky-950 flex items-center gap-1.5">
610
+ <Sparkles className="w-3.5 h-3.5 text-sky-600" />
611
+ Chế độ in nhanh KTS:
612
+ </span>
613
+ <div className="flex gap-1 bg-white p-0.5 rounded-lg border border-sky-200 text-xs">
614
+ <button
615
+ type="button"
616
+ onClick={() => setDigitalMode('with_paper')}
617
+ className={`px-2.5 py-1 rounded font-semibold text-[11px] transition-colors ${
618
+ digitalMode === 'with_paper'
619
+ ? 'bg-sky-600 text-white shadow-2xs'
620
+ : 'text-slate-600 hover:text-slate-900'
621
+ }`}
622
+ >
623
+ 📦 Kèm giấy (INTC)
624
+ </button>
625
+ <button
626
+ type="button"
627
+ onClick={() => setDigitalMode('without_paper')}
628
+ className={`px-2.5 py-1 rounded font-semibold text-[11px] transition-colors ${
629
+ digitalMode === 'without_paper'
630
+ ? 'bg-sky-600 text-white shadow-2xs'
631
+ : 'text-slate-600 hover:text-slate-900'
632
+ }`}
633
+ >
634
+ ⚙️ In gia công (Konica C12000)
635
+ </button>
636
+ </div>
637
+ </div>
638
+
639
+ {digitalMode === 'with_paper' ? (
640
+ <div className="text-[11px] text-sky-800 bg-white/70 p-2 rounded-lg border border-sky-100">
641
+ <strong>Bảng giá INTC (14.03.2026):</strong> Trọn gói công in + giấy theo mã giấy. Khổ in 325&times;430 & 325&times;355mm. Phụ phí SL ít: &lt;50 tờ (+30k), &lt;100 tờ (+20k), &ge;100 tờ miễn phí.
642
+ </div>
643
+ ) : (
644
+ <div className="text-[11px] text-sky-800 bg-white/70 p-2 rounded-lg border border-sky-100 space-y-2">
645
+ <div>
646
+ <strong>Bảng giá Konica C12000 (Trang 2 PDF 26.02.2026):</strong> 5 khổ in (A4, 330&times;355, A3, 330&times;483, 330&times;1200) cho 7 nhóm chất liệu. In 2 mặt &times;2. Phụ phí SL ít: &lt;50 tờ (+30k), &lt;100 tờ (+20k).
647
+ </div>
648
+ <div className="flex flex-wrap gap-4 text-xs font-semibold text-slate-800 pt-1 border-t border-sky-100">
649
+ <label className="flex items-center gap-1.5 cursor-pointer">
650
+ <input
651
+ type="checkbox"
652
+ checked={customerSuppliedPaper}
653
+ onChange={(e) => setCustomerSuppliedPaper(e.target.checked)}
654
+ className="rounded text-sky-600 focus:ring-sky-500 w-3.5 h-3.5"
655
+ />
656
+ <span>Khách tự cấp giấy (Tiền giấy = 0đ)</span>
657
+ </label>
658
+ <label className="flex items-center gap-1.5 cursor-pointer">
659
+ <input
660
+ type="checkbox"
661
+ checked={whiteInk}
662
+ onChange={(e) => setWhiteInk(e.target.checked)}
663
+ className="rounded text-sky-600 focus:ring-sky-500 w-3.5 h-3.5"
664
+ />
665
+ <span>In mực trắng (5 màu)</span>
666
+ </label>
667
+ </div>
668
+ </div>
669
+ )}
670
+ </div>
671
+ )}
672
  </div>
673
  </div>
674
 
src/components/CostBreakdownCard.tsx CHANGED
@@ -112,17 +112,52 @@ ${costs.finishingDetails.length > 0 ? `• Gia công: ${costs.finishingDetails.m
112
  <Layers className="w-3.5 h-3.5 text-blue-600" />
113
  Tiền giấy ({quantities.parentSheetsNeeded} tờ mẹ):
114
  </span>
115
- <span className="font-semibold text-slate-900">{costs.paperCost.toLocaleString('vi-VN')}đ</span>
 
 
 
 
 
 
 
 
 
 
 
 
116
  </div>
117
 
118
  <div className="py-2 flex justify-between items-center">
119
  <span className="flex items-center gap-1.5 text-slate-600">
120
  <Sparkles className="w-3.5 h-3.5 text-indigo-600" />
121
- Tiền in {chosenTech === 'offset' ? (costs.plateCost === 0 ? '(Gói trọn gồm kẽm)' : `(${costs.platesCount} kẽm + công)`) : '(click in)'}:
122
  </span>
123
  <span className="font-semibold text-slate-900">{costs.totalPrintCost.toLocaleString('vi-VN')}đ</span>
124
  </div>
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  <div className="py-2 flex justify-between items-center">
127
  <span className="flex items-center gap-1.5 text-slate-600">
128
  <Scissors className="w-3.5 h-3.5 text-purple-600" />
 
112
  <Layers className="w-3.5 h-3.5 text-blue-600" />
113
  Tiền giấy ({quantities.parentSheetsNeeded} tờ mẹ):
114
  </span>
115
+ <span className="font-semibold text-slate-900">
116
+ {costs.paperCost === 0 && costs.digitalDetails?.mode === 'with_paper' ? (
117
+ <span className="text-emerald-600 font-bold text-xs bg-emerald-50 px-2 py-0.5 rounded">
118
+ Đã gồm trong giá in
119
+ </span>
120
+ ) : costs.paperCost === 0 && input.customerSuppliedPaper ? (
121
+ <span className="text-emerald-600 font-bold text-xs bg-emerald-50 px-2 py-0.5 rounded">
122
+ Khách tự cấp giấy
123
+ </span>
124
+ ) : (
125
+ `${costs.paperCost.toLocaleString('vi-VN')}đ`
126
+ )}
127
+ </span>
128
  </div>
129
 
130
  <div className="py-2 flex justify-between items-center">
131
  <span className="flex items-center gap-1.5 text-slate-600">
132
  <Sparkles className="w-3.5 h-3.5 text-indigo-600" />
133
+ Tiền in {chosenTech === 'offset' ? (costs.plateCost === 0 ? '(Gói trọn gồm kẽm)' : `(${costs.platesCount} kẽm + công)`) : `(${costs.digitalDetails?.mode === 'with_paper' ? 'Kèm giấy INTC' : 'Gia công Konica'})`}:
134
  </span>
135
  <span className="font-semibold text-slate-900">{costs.totalPrintCost.toLocaleString('vi-VN')}đ</span>
136
  </div>
137
 
138
+ {chosenTech === 'digital' && costs.digitalDetails && (
139
+ <div className="py-2 px-3 bg-sky-50/80 border border-sky-100 rounded-xl text-[11px] text-sky-900 space-y-1 my-1">
140
+ <div className="flex justify-between font-bold">
141
+ <span>
142
+ {costs.digitalDetails.mode === 'with_paper'
143
+ ? '📦 In kèm giấy (INTC 14.03)'
144
+ : '⚙️ In gia công (Konica C12000)'}
145
+ </span>
146
+ <span className="text-sky-700">Khổ: {costs.digitalDetails.sheetSizeName || imposition.printSheet.name}</span>
147
+ </div>
148
+ <div className="flex justify-between text-slate-600">
149
+ <span>Đơn giá: {costs.digitalDetails.ratePerSheet.toLocaleString('vi-VN')}đ / tờ in</span>
150
+ {costs.shortRunFee && costs.shortRunFee > 0 ? (
151
+ <span className="font-bold text-amber-700 bg-amber-50 px-1.5 py-0.5 rounded">
152
+ Phụ phí SL ít: +{costs.shortRunFee.toLocaleString('vi-VN')}đ
153
+ </span>
154
+ ) : (
155
+ <span className="text-emerald-700 font-semibold">Miễn phụ phí</span>
156
+ )}
157
+ </div>
158
+ </div>
159
+ )}
160
+
161
  <div className="py-2 flex justify-between items-center">
162
  <span className="flex items-center gap-1.5 text-slate-600">
163
  <Scissors className="w-3.5 h-3.5 text-purple-600" />
src/components/MachineManager.tsx CHANGED
@@ -1,15 +1,24 @@
1
  'use client';
2
 
3
  import React, { useState, useEffect } from 'react';
4
- import { DigitalMachine, OffsetMachine } from '@/types';
5
- import { Cpu, Printer, Edit2, Check, RefreshCw } from 'lucide-react';
6
 
7
  export const MachineManager: React.FC = () => {
 
8
  const [offsetMachines, setOffsetMachines] = useState<OffsetMachine[]>([]);
9
  const [digitalMachines, setDigitalMachines] = useState<DigitalMachine[]>([]);
 
 
10
  const [loading, setLoading] = useState(true);
 
 
11
  const [editingOffset, setEditingOffset] = useState<OffsetMachine | null>(null);
12
- const [editingDigital, setEditingDigital] = useState<DigitalMachine | null>(null);
 
 
 
 
13
 
14
  const fetchMachines = async () => {
15
  try {
@@ -18,6 +27,8 @@ export const MachineManager: React.FC = () => {
18
  const data = await res.json();
19
  if (data.offsetMachines) setOffsetMachines(data.offsetMachines);
20
  if (data.digitalMachines) setDigitalMachines(data.digitalMachines);
 
 
21
  } catch (err) {
22
  console.error(err);
23
  } finally {
@@ -45,15 +56,31 @@ export const MachineManager: React.FC = () => {
45
  }
46
  };
47
 
48
- const handleSaveDigital = async (m: DigitalMachine) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  try {
50
  const res = await fetch('/api/machines', {
51
  method: 'PUT',
52
  headers: { 'Content-Type': 'application/json' },
53
- body: JSON.stringify({ type: 'digital', ...m }),
54
  });
55
  if (res.ok) {
56
- setEditingDigital(null);
57
  fetchMachines();
58
  }
59
  } catch (err) {
@@ -61,301 +88,597 @@ export const MachineManager: React.FC = () => {
61
  }
62
  };
63
 
 
 
 
 
 
 
 
 
64
  return (
65
- <div className="space-y-6">
66
- {/* MÁY IN OFFSET */}
67
- <div className="bg-white rounded-xl border border-slate-200 shadow-xs overflow-hidden">
68
- <div className="p-4 border-b border-slate-200 bg-slate-50 flex items-center justify-between">
69
- <div className="flex items-center gap-2">
70
- <Cpu className="w-5 h-5 text-indigo-600" />
71
- <div>
72
- <h3 className="font-bold text-slate-800 text-sm">Cấu Hình Máy In Offset & Đơn Giá Kẽm, Công In</h3>
73
- <p className="text-xs text-slate-500">Thiết lập khổ máy, giá khuôn kẽm CTP, phí mở máy và lũy tiến</p>
74
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  </div>
76
- <button onClick={fetchMachines} className="p-2 text-slate-500 hover:text-indigo-600 rounded">
77
- <RefreshCw className="w-4 h-4" />
 
 
 
 
 
 
78
  </button>
79
  </div>
 
80
 
81
- <div className="p-4 grid grid-cols-1 md:grid-cols-3 gap-4">
82
- {offsetMachines.map((m) => {
83
- const isEditing = editingOffset?.id === m.id;
84
- const current = isEditing ? editingOffset : m;
85
-
86
- return (
87
- <div key={m.id} className="border border-slate-200 rounded-xl p-4 bg-slate-50/50 flex flex-col justify-between space-y-3">
88
- <div className="flex items-center justify-between border-b border-slate-200 pb-2">
89
- <strong className="text-xs font-bold text-slate-900">{current.name}</strong>
90
- <button
91
- onClick={() => setEditingOffset(isEditing ? null : { ...m })}
92
- className="p-1 text-slate-400 hover:text-indigo-600"
93
- >
94
- <Edit2 className="w-3.5 h-3.5" />
95
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
96
  </div>
 
97
 
98
- <div className="space-y-2 text-xs">
99
- <div className="flex justify-between items-center">
100
- <span className="text-slate-500">Khổ in max:</span>
101
- {isEditing ? (
102
- <div className="flex gap-1 w-32">
103
- <input
104
- type="number"
105
- className="w-1/2 p-1 border rounded text-right"
106
- value={current.maxWidthMm}
107
- onChange={(e) => setEditingOffset({ ...current, maxWidthMm: Number(e.target.value) })}
108
- />
109
- <input
110
- type="number"
111
- className="w-1/2 p-1 border rounded text-right"
112
- value={current.maxHeightMm}
113
- onChange={(e) => setEditingOffset({ ...current, maxHeightMm: Number(e.target.value) })}
114
- />
115
- </div>
116
- ) : (
117
- <span className="font-medium text-slate-800">{current.maxWidthMm} × {current.maxHeightMm} mm</span>
118
- )}
119
- </div>
120
 
121
- <div className="flex justify-between items-center">
122
- <span className="text-slate-500">Giá kẽm CTP / lá:</span>
123
- {isEditing ? (
124
- <input
125
- type="number"
126
- className="w-28 p-1 border rounded text-right"
127
- value={current.platePrice}
128
- onChange={(e) => setEditingOffset({ ...current, platePrice: Number(e.target.value) })}
129
- />
130
- ) : (
131
- <span className="font-bold text-indigo-700">{current.platePrice.toLocaleString('vi-VN')}đ</span>
132
- )}
133
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
 
135
- <div className="flex justify-between items-center">
136
- <span className="text-slate-500">Mở máy (&le;{current.baseImpressions || 3000} lượt):</span>
137
- {isEditing ? (
138
- <input
139
- type="number"
140
- className="w-28 p-1 border rounded text-right"
141
- value={current.setupCost}
142
- onChange={(e) => setEditingOffset({ ...current, setupCost: Number(e.target.value) })}
143
- />
144
- ) : (
145
- <div className="text-right">
146
- <span className="font-bold text-slate-800">{current.setupCost.toLocaleString('vi-VN')}đ</span>
147
- {current.includesPlate && (
148
- <span className="text-[10px] font-semibold text-emerald-600 block">Đã gồm kẽm</span>
149
  )}
150
- </div>
151
- )}
152
- </div>
153
 
154
- <div className="flex justify-between items-center">
155
- <span className="text-slate-500">Số lượt in cơ bản:</span>
156
- {isEditing ? (
157
- <input
158
- type="number"
159
- className="w-28 p-1 border rounded text-right"
160
- value={current.baseImpressions || 3000}
161
- onChange={(e) => setEditingOffset({ ...current, baseImpressions: Number(e.target.value) })}
162
- />
163
- ) : (
164
- <span className="font-medium text-slate-700">{(current.baseImpressions || 3000).toLocaleString('vi-VN')} lượt</span>
165
- )}
166
- </div>
167
 
168
- <div className="flex justify-between items-center">
169
- <span className="text-slate-500">Bao gồm kẽm CTP:</span>
170
- {isEditing ? (
171
- <input
172
- type="checkbox"
173
- className="h-4 w-4 text-indigo-600 rounded"
174
- checked={current.includesPlate ?? true}
175
- onChange={(e) => setEditingOffset({ ...current, includesPlate: e.target.checked })}
176
- />
177
- ) : (
178
- <span className={`font-semibold ${current.includesPlate ? 'text-emerald-600' : 'text-slate-500'}`}>
179
- {current.includesPlate ? 'Đã gồm trong giá mở máy' : 'Tính riêng theo kẽm'}
180
- </span>
181
- )}
182
- </div>
183
 
184
- <div className="flex justify-between items-center">
185
- <span className="text-slate-500">Mỗi 1000 lượt tiếp:</span>
186
- {isEditing ? (
187
- <input
188
- type="number"
189
- className="w-28 p-1 border rounded text-right"
190
- value={current.stepCost}
191
- onChange={(e) => setEditingOffset({ ...current, stepCost: Number(e.target.value) })}
192
- />
193
- ) : (
194
- <span className="font-medium text-slate-700">{current.stepCost.toLocaleString('vi-VN')}đ</span>
195
- )}
196
- </div>
197
 
198
- <div className="flex justify-between items-center">
199
- <span className="text-slate-500">Bù hao canh màu:</span>
200
- {isEditing ? (
201
- <input
202
- type="number"
203
- className="w-28 p-1 border rounded text-right"
204
- value={current.defaultWasteSheets}
205
- onChange={(e) => setEditingOffset({ ...current, defaultWasteSheets: Number(e.target.value) })}
206
- />
207
- ) : (
208
- <span className="font-medium text-amber-700">{current.defaultWasteSheets} tờ</span>
209
- )}
210
- </div>
211
- </div>
212
 
213
- {isEditing && (
214
- <div className="pt-2 border-t border-slate-200 flex justify-end gap-1.5">
215
- <button
216
- onClick={() => setEditingOffset(null)}
217
- className="px-2 py-1 border rounded text-[11px] text-slate-600 hover:bg-slate-100"
218
- >
219
- Hủy
220
- </button>
221
- <button
222
- onClick={() => handleSaveOffset(current)}
223
- className="px-2.5 py-1 bg-indigo-600 text-white rounded text-[11px] font-semibold hover:bg-indigo-700"
224
- >
225
- Lưu
226
- </button>
227
- </div>
228
- )}
229
- </div>
230
- );
231
- })}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  </div>
233
- </div>
234
 
235
- {/* MÁY IN NHANH KTS */}
236
- <div className="bg-white rounded-xl border border-slate-200 shadow-xs overflow-hidden">
237
- <div className="p-4 border-b border-slate-200 bg-slate-50 flex items-center justify-between">
238
- <div className="flex items-center gap-2">
239
- <Printer className="w-5 h-5 text-sky-600" />
240
- <div>
241
- <h3 className="font-bold text-slate-800 text-sm">Bảng Giá Máy In Nhanh Kỹ Thuật Số (Digital Click)</h3>
242
- <p className="text-xs text-slate-500">Đơn giá in theo click A4, A3 1 mặt và 2 mặt</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  </div>
244
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  </div>
 
246
 
247
- <div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
248
- {digitalMachines.map((m) => {
249
- const isEditing = editingDigital?.id === m.id;
250
- const current = isEditing ? editingDigital : m;
251
-
252
- return (
253
- <div key={m.id} className="border border-slate-200 rounded-xl p-4 bg-slate-50/50 flex flex-col justify-between space-y-3">
254
- <div className="flex items-center justify-between border-b border-slate-200 pb-2">
255
- <strong className="text-xs font-bold text-slate-900">{current.name}</strong>
256
- <button
257
- onClick={() => setEditingDigital(isEditing ? null : { ...m })}
258
- className="p-1 text-slate-400 hover:text-sky-600"
259
- >
260
- <Edit2 className="w-3.5 h-3.5" />
261
- </button>
262
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
263
 
264
- <div className="space-y-2 text-xs">
265
- <div className="grid grid-cols-2 gap-2">
266
- <div className="p-2 bg-white rounded border border-slate-200">
267
- <span className="text-slate-400 block text-[10px]">Click A4 1 mặt</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  {isEditing ? (
269
  <input
270
  type="number"
271
- className="w-full p-1 border rounded text-right mt-1"
272
- value={current.clickA41Side}
273
- onChange={(e) => setEditingDigital({ ...current, clickA41Side: Number(e.target.value) })}
274
  />
275
  ) : (
276
- <strong className="text-slate-800 text-sm">{current.clickA41Side.toLocaleString('vi-VN')}đ</strong>
277
  )}
278
  </div>
279
- <div className="p-2 bg-white rounded border border-slate-200">
280
- <span className="text-slate-400 block text-[10px]">Click A4 2 mặt</span>
 
281
  {isEditing ? (
282
  <input
283
  type="number"
284
- className="w-full p-1 border rounded text-right mt-1"
285
- value={current.clickA42Side}
286
- onChange={(e) => setEditingDigital({ ...current, clickA42Side: Number(e.target.value) })}
287
  />
288
  ) : (
289
- <strong className="text-slate-800 text-sm">{current.clickA42Side.toLocaleString('vi-VN')}đ</strong>
 
 
 
 
 
290
  )}
291
  </div>
292
- </div>
293
 
294
- <div className="grid grid-cols-2 gap-2">
295
- <div className="p-2 bg-white rounded border border-slate-200">
296
- <span className="text-slate-400 block text-[10px]">Click A3/A3+ 1 mặt</span>
297
  {isEditing ? (
298
  <input
299
  type="number"
300
- className="w-full p-1 border rounded text-right mt-1"
301
- value={current.clickA31Side}
302
- onChange={(e) => setEditingDigital({ ...current, clickA31Side: Number(e.target.value) })}
303
  />
304
  ) : (
305
- <strong className="text-sky-700 text-sm">{current.clickA31Side.toLocaleString('vi-VN')}đ</strong>
306
  )}
307
  </div>
308
- <div className="p-2 bg-white rounded border border-slate-200">
309
- <span className="text-slate-400 block text-[10px]">Click A3/A3+ 2 mặt</span>
 
310
  {isEditing ? (
311
  <input
312
  type="number"
313
- className="w-full p-1 border rounded text-right mt-1"
314
- value={current.clickA32Side}
315
- onChange={(e) => setEditingDigital({ ...current, clickA32Side: Number(e.target.value) })}
316
  />
317
  ) : (
318
- <strong className="text-sky-700 text-sm">{current.clickA32Side.toLocaleString('vi-VN')}đ</strong>
319
  )}
320
  </div>
321
- </div>
322
 
323
- <div className="flex justify-between items-center pt-1">
324
- <span className="text-slate-500">Phí tối thiểu / bài:</span>
325
- {isEditing ? (
326
- <input
327
- type="number"
328
- className="w-28 p-1 border rounded text-right"
329
- value={current.minCharge}
330
- onChange={(e) => setEditingDigital({ ...current, minCharge: Number(e.target.value) })}
331
- />
332
- ) : (
333
- <span className="font-semibold text-slate-800">{current.minCharge.toLocaleString('vi-VN')}đ</span>
334
- )}
 
335
  </div>
336
- </div>
337
 
338
- {isEditing && (
339
- <div className="pt-2 border-t border-slate-200 flex justify-end gap-1.5">
340
- <button
341
- onClick={() => setEditingDigital(null)}
342
- className="px-2 py-1 border rounded text-[11px] text-slate-600 hover:bg-slate-100"
343
- >
344
- Hủy
345
- </button>
346
- <button
347
- onClick={() => handleSaveDigital(current)}
348
- className="px-2.5 py-1 bg-sky-600 text-white rounded text-[11px] font-semibold hover:bg-sky-700"
349
- >
350
- Lưu
351
- </button>
352
- </div>
353
- )}
354
- </div>
355
- );
356
- })}
 
357
  </div>
358
- </div>
359
  </div>
360
  );
361
  };
 
1
  'use client';
2
 
3
  import React, { useState, useEffect } from 'react';
4
+ import { DigitalMachine, DigitalPrintGiaCong, DigitalPrintKemGiay, OffsetMachine } from '@/types';
5
+ import { Cpu, Printer, Edit2, Check, RefreshCw, Layers, FileText, Info, Save, X } from 'lucide-react';
6
 
7
  export const MachineManager: React.FC = () => {
8
+ const [activeTab, setActiveTab] = useState<'giacong' | 'kem_giay' | 'offset'>('giacong');
9
  const [offsetMachines, setOffsetMachines] = useState<OffsetMachine[]>([]);
10
  const [digitalMachines, setDigitalMachines] = useState<DigitalMachine[]>([]);
11
+ const [digitalPricingGiaCong, setDigitalPricingGiaCong] = useState<DigitalPrintGiaCong[]>([]);
12
+ const [digitalPricingKemGiay, setDigitalPricingKemGiay] = useState<DigitalPrintKemGiay[]>([]);
13
  const [loading, setLoading] = useState(true);
14
+
15
+ // Editing state
16
  const [editingOffset, setEditingOffset] = useState<OffsetMachine | null>(null);
17
+ const [editingGiaCong, setEditingGiaCong] = useState<DigitalPrintGiaCong | null>(null);
18
+ const [editingKemGiay, setEditingKemGiay] = useState<DigitalPrintKemGiay | null>(null);
19
+
20
+ // Filter for INTC paper
21
+ const [paperFilter, setPaperFilter] = useState<'all' | 'C' | 'I' | 'F'>('all');
22
 
23
  const fetchMachines = async () => {
24
  try {
 
27
  const data = await res.json();
28
  if (data.offsetMachines) setOffsetMachines(data.offsetMachines);
29
  if (data.digitalMachines) setDigitalMachines(data.digitalMachines);
30
+ if (data.digitalPricingGiaCong) setDigitalPricingGiaCong(data.digitalPricingGiaCong);
31
+ if (data.digitalPricingKemGiay) setDigitalPricingKemGiay(data.digitalPricingKemGiay);
32
  } catch (err) {
33
  console.error(err);
34
  } finally {
 
56
  }
57
  };
58
 
59
+ const handleSaveGiaCong = async (item: DigitalPrintGiaCong) => {
60
+ try {
61
+ const res = await fetch('/api/machines', {
62
+ method: 'PUT',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ body: JSON.stringify({ type: 'digital_giacong', ...item }),
65
+ });
66
+ if (res.ok) {
67
+ setEditingGiaCong(null);
68
+ fetchMachines();
69
+ }
70
+ } catch (err) {
71
+ console.error(err);
72
+ }
73
+ };
74
+
75
+ const handleSaveKemGiay = async (item: DigitalPrintKemGiay) => {
76
  try {
77
  const res = await fetch('/api/machines', {
78
  method: 'PUT',
79
  headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify({ type: 'digital_kem_giay', ...item }),
81
  });
82
  if (res.ok) {
83
+ setEditingKemGiay(null);
84
  fetchMachines();
85
  }
86
  } catch (err) {
 
88
  }
89
  };
90
 
91
+ const filteredKemGiay = digitalPricingKemGiay.filter((item) => {
92
+ if (paperFilter === 'all') return true;
93
+ if (paperFilter === 'C') return item.paperCode.startsWith('C');
94
+ if (paperFilter === 'I') return item.paperCode.startsWith('I');
95
+ if (paperFilter === 'F') return item.paperCode.startsWith('F');
96
+ return true;
97
+ });
98
+
99
  return (
100
+ <div className="space-y-5">
101
+ {/* Top Header & Navigation Tabs */}
102
+ <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-white p-4 rounded-2xl border border-slate-200 shadow-2xs">
103
+ <div>
104
+ <h2 className="text-base font-bold text-slate-900 flex items-center gap-2">
105
+ <Printer className="w-5 h-5 text-blue-600" />
106
+ Quản Lý Bảng Giá In Ấn
107
+ </h2>
108
+ <p className="text-xs text-slate-500 mt-0.5">
109
+ Cập nhật biểu giá In nhanh gia công (Konica C12000), In nhanh kèm giấy (INTC) và Máy in Offset
110
+ </p>
111
+ </div>
112
+
113
+ <div className="flex items-center gap-2">
114
+ <div className="flex bg-slate-100 p-1 rounded-xl text-xs font-semibold">
115
+ <button
116
+ onClick={() => setActiveTab('giacong')}
117
+ className={`px-3 py-1.5 rounded-lg transition-all ${
118
+ activeTab === 'giacong'
119
+ ? 'bg-white shadow text-sky-700 font-bold'
120
+ : 'text-slate-600 hover:text-slate-900'
121
+ }`}
122
+ >
123
+ ⚙️ Konica C12000 (Gia Công)
124
+ </button>
125
+ <button
126
+ onClick={() => setActiveTab('kem_giay')}
127
+ className={`px-3 py-1.5 rounded-lg transition-all ${
128
+ activeTab === 'kem_giay'
129
+ ? 'bg-white shadow text-indigo-700 font-bold'
130
+ : 'text-slate-600 hover:text-slate-900'
131
+ }`}
132
+ >
133
+ 📦 INTC (Kèm Giấy)
134
+ </button>
135
+ <button
136
+ onClick={() => setActiveTab('offset')}
137
+ className={`px-3 py-1.5 rounded-lg transition-all ${
138
+ activeTab === 'offset'
139
+ ? 'bg-white shadow text-blue-700 font-bold'
140
+ : 'text-slate-600 hover:text-slate-900'
141
+ }`}
142
+ >
143
+ 🏭 Máy In Offset
144
+ </button>
145
  </div>
146
+
147
+ <button
148
+ onClick={fetchMachines}
149
+ disabled={loading}
150
+ className="p-2 text-slate-500 hover:text-blue-600 rounded-lg hover:bg-slate-100 transition-colors"
151
+ title="Làm mới dữ liệu"
152
+ >
153
+ <RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
154
  </button>
155
  </div>
156
+ </div>
157
 
158
+ {/* TAB 1: BẢNG GIÁ IN GIA CÔNG KONICA C12000 (TRANG 2 PDF 26.02.2026) */}
159
+ {activeTab === 'giacong' && (
160
+ <div className="bg-white rounded-2xl border border-slate-200 shadow-2xs overflow-hidden space-y-4">
161
+ <div className="p-5 border-b border-slate-200 bg-gradient-to-r from-sky-50 to-blue-50/40">
162
+ <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
163
+ <div>
164
+ <span className="px-2.5 py-0.5 bg-sky-600 text-white rounded-md text-[10px] font-bold uppercase tracking-wider">
165
+ Trang 2 PDF 26.02.2026
166
+ </span>
167
+ <h3 className="text-base font-black text-slate-900 mt-1">
168
+ MÁY IN KONICA MINOLTA C12000 / C12010S 5 MÀU
169
+ </h3>
170
+ <p className="text-xs text-slate-600 mt-0.5">
171
+ Đơn giá in gia công (chưa gồm giấy) theo 5 kích thước tiêu chuẩn & 7 nhóm chất liệu.
172
+ </p>
173
+ </div>
174
+ </div>
175
+
176
+ {/* Quy tắc phụ phí & lưu ý */}
177
+ <div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-2 text-xs">
178
+ <div className="bg-white/80 border border-sky-200 rounded-xl p-2.5 flex items-start gap-2">
179
+ <Info className="w-4 h-4 text-sky-600 shrink-0 mt-0.5" />
180
+ <div>
181
+ <strong className="text-sky-900 block font-semibold">Phụ phí số lượng ít:</strong>
182
+ <span className="text-slate-600 text-[11px]">
183
+ &lt; 50 tờ: <strong>+30.000đ</strong> | &lt; 100 tờ: <strong>+20.000đ</strong> | &ge; 100 tờ: <strong>Miễn phí</strong>
184
+ </span>
185
  </div>
186
+ </div>
187
 
188
+ <div className="bg-white/80 border border-sky-200 rounded-xl p-2.5 flex items-start gap-2">
189
+ <Info className="w-4 h-4 text-indigo-600 shrink-0 mt-0.5" />
190
+ <div>
191
+ <strong className="text-indigo-900 block font-semibold">Quy ước tính mặt in:</strong>
192
+ <span className="text-slate-600 text-[11px]">
193
+ Đơn giá hiển thị dưới đây là đơn giá <strong>in 1 mặt</strong>. In 2 mặt được tính <strong>nhân đôi (&times;2)</strong>.
194
+ </span>
195
+ </div>
196
+ </div>
197
+ </div>
198
+ </div>
 
 
 
 
 
 
 
 
 
 
 
199
 
200
+ <div className="p-5 pt-0 overflow-x-auto">
201
+ <table className="w-full text-xs text-left border-collapse min-w-[760px]">
202
+ <thead>
203
+ <tr className="bg-slate-100 text-slate-700 font-bold border-b border-slate-200">
204
+ <th className="p-3 text-center w-36">Khổ in</th>
205
+ <th className="p-3 text-right">Giấy &lt;249gsm</th>
206
+ <th className="p-3 text-right">Giấy 250-349gsm</th>
207
+ <th className="p-3 text-right">Giấy 350-450gsm</th>
208
+ <th className="p-3 text-right">Decal (Giấy-nhựa)</th>
209
+ <th className="p-3 text-right">Decal trong</th>
210
+ <th className="p-3 text-right">Giấy nhựa &lt;280gsm</th>
211
+ <th className="p-3 text-right">Nhựa PVC</th>
212
+ <th className="p-3 text-center w-24">Thao tác</th>
213
+ </tr>
214
+ </thead>
215
+ <tbody className="divide-y divide-slate-100">
216
+ {digitalPricingGiaCong.map((row) => {
217
+ const isEditing = editingGiaCong?.id === row.id;
218
+ const cur = isEditing ? editingGiaCong : row;
219
+
220
+ return (
221
+ <tr key={row.id} className="hover:bg-slate-50/70 transition-colors">
222
+ <td className="p-3 text-center font-bold text-slate-800">
223
+ <span className="text-sm block">{row.sheetSize}</span>
224
+ <span className="text-[10px] text-slate-500 font-normal">
225
+ {row.widthMm} &times; {row.heightMm} mm
226
+ </span>
227
+ </td>
228
 
229
+ {/* Giấy <249gsm */}
230
+ <td className="p-2 text-right">
231
+ {isEditing ? (
232
+ <input
233
+ type="number"
234
+ className="w-20 p-1 border rounded text-right font-bold text-blue-700 text-xs"
235
+ value={cur.paperLt249}
236
+ onChange={(e) => setEditingGiaCong({ ...cur, paperLt249: Number(e.target.value) })}
237
+ />
238
+ ) : (
239
+ <span className="font-bold text-slate-800">{row.paperLt249.toLocaleString('vi-VN')}đ</span>
 
 
 
240
  )}
241
+ </td>
 
 
242
 
243
+ {/* Giấy 250-349gsm */}
244
+ <td className="p-2 text-right">
245
+ {isEditing ? (
246
+ <input
247
+ type="number"
248
+ className="w-20 p-1 border rounded text-right font-bold text-blue-700 text-xs"
249
+ value={cur.paper250To349}
250
+ onChange={(e) => setEditingGiaCong({ ...cur, paper250To349: Number(e.target.value) })}
251
+ />
252
+ ) : (
253
+ <span className="font-bold text-slate-800">{row.paper250To349.toLocaleString('vi-VN')}đ</span>
254
+ )}
255
+ </td>
256
 
257
+ {/* Giấy 350-450gsm */}
258
+ <td className="p-2 text-right">
259
+ {isEditing ? (
260
+ <input
261
+ type="number"
262
+ className="w-20 p-1 border rounded text-right font-bold text-blue-700 text-xs"
263
+ value={cur.paper350To450}
264
+ onChange={(e) => setEditingGiaCong({ ...cur, paper350To450: Number(e.target.value) })}
265
+ />
266
+ ) : (
267
+ <span className="font-bold text-slate-800">{row.paper350To450.toLocaleString('vi-VN')}đ</span>
268
+ )}
269
+ </td>
 
 
270
 
271
+ {/* Decal giấy nhựa */}
272
+ <td className="p-2 text-right">
273
+ {isEditing ? (
274
+ <input
275
+ type="number"
276
+ className="w-20 p-1 border rounded text-right font-bold text-blue-700 text-xs"
277
+ value={cur.decalPaperPlastic}
278
+ onChange={(e) => setEditingGiaCong({ ...cur, decalPaperPlastic: Number(e.target.value) })}
279
+ />
280
+ ) : (
281
+ <span className="font-medium text-slate-700">{row.decalPaperPlastic.toLocaleString('vi-VN')}đ</span>
282
+ )}
283
+ </td>
284
 
285
+ {/* Decal trong */}
286
+ <td className="p-2 text-right">
287
+ {isEditing ? (
288
+ <input
289
+ type="number"
290
+ className="w-20 p-1 border rounded text-right font-bold text-blue-700 text-xs"
291
+ value={cur.decalClear}
292
+ onChange={(e) => setEditingGiaCong({ ...cur, decalClear: Number(e.target.value) })}
293
+ />
294
+ ) : (
295
+ <span className="font-medium text-slate-700">{row.decalClear.toLocaleString('vi-VN')}đ</span>
296
+ )}
297
+ </td>
 
298
 
299
+ {/* Giấy nhựa <280 */}
300
+ <td className="p-2 text-right">
301
+ {isEditing ? (
302
+ <input
303
+ type="number"
304
+ className="w-20 p-1 border rounded text-right font-bold text-blue-700 text-xs"
305
+ value={cur.syntheticPaper}
306
+ onChange={(e) => setEditingGiaCong({ ...cur, syntheticPaper: Number(e.target.value) })}
307
+ />
308
+ ) : (
309
+ <span className="font-medium text-slate-700">{row.syntheticPaper.toLocaleString('vi-VN')}đ</span>
310
+ )}
311
+ </td>
312
+
313
+ {/* Nhựa PVC */}
314
+ <td className="p-2 text-right">
315
+ {isEditing ? (
316
+ <input
317
+ type="number"
318
+ className="w-20 p-1 border rounded text-right font-bold text-blue-700 text-xs"
319
+ value={cur.pvcPlastic}
320
+ onChange={(e) => setEditingGiaCong({ ...cur, pvcPlastic: Number(e.target.value) })}
321
+ />
322
+ ) : (
323
+ <span className="font-medium text-slate-700">
324
+ {row.pvcPlastic > 0 ? `${row.pvcPlastic.toLocaleString('vi-VN')}đ` : '—'}
325
+ </span>
326
+ )}
327
+ </td>
328
+
329
+ {/* Nút hành động */}
330
+ <td className="p-2 text-center">
331
+ {isEditing ? (
332
+ <div className="flex items-center justify-center gap-1">
333
+ <button
334
+ onClick={() => handleSaveGiaCong(cur)}
335
+ className="p-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded shadow-xs"
336
+ title="Lưu thay đổi"
337
+ >
338
+ <Save className="w-3.5 h-3.5" />
339
+ </button>
340
+ <button
341
+ onClick={() => setEditingGiaCong(null)}
342
+ className="p-1.5 bg-slate-200 hover:bg-slate-300 text-slate-700 rounded"
343
+ title="Hủy"
344
+ >
345
+ <X className="w-3.5 h-3.5" />
346
+ </button>
347
+ </div>
348
+ ) : (
349
+ <button
350
+ onClick={() => setEditingGiaCong({ ...row })}
351
+ className="p-1.5 text-slate-400 hover:text-sky-600 rounded hover:bg-sky-50 transition-colors"
352
+ title="Chỉnh sửa đơn giá hàng này"
353
+ >
354
+ <Edit2 className="w-3.5 h-3.5" />
355
+ </button>
356
+ )}
357
+ </td>
358
+ </tr>
359
+ );
360
+ })}
361
+ </tbody>
362
+ </table>
363
+ </div>
364
  </div>
365
+ )}
366
 
367
+ {/* TAB 2: BẢNG GIÁ IN NHANH KÈM GIẤY INTC (PDF 14.03.2026) */}
368
+ {activeTab === 'kem_giay' && (
369
+ <div className="bg-white rounded-2xl border border-slate-200 shadow-2xs overflow-hidden space-y-4">
370
+ <div className="p-5 border-b border-slate-200 bg-gradient-to-r from-indigo-50 to-purple-50/40">
371
+ <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
372
+ <div>
373
+ <span className="px-2.5 py-0.5 bg-indigo-600 text-white rounded-md text-[10px] font-bold uppercase tracking-wider">
374
+ Bảng Giá INTC 14.03.2026
375
+ </span>
376
+ <h3 className="text-base font-black text-slate-900 mt-1">
377
+ BẢNG GIÁ IN NHANH KÈM GIẤY (TRỌN GÓI)
378
+ </h3>
379
+ <p className="text-xs text-slate-600 mt-0.5">
380
+ Đơn giá trọn gói đã bao gồm tiền giấy và công in. Khổ in 325 &times; 430 mm và 325 &times; 355 mm.
381
+ </p>
382
+ </div>
383
+
384
+ {/* Bộ lọc loại giấy */}
385
+ <div className="flex items-center gap-1 bg-white p-1 rounded-xl border border-indigo-200 text-xs font-semibold">
386
+ <button
387
+ onClick={() => setPaperFilter('all')}
388
+ className={`px-2.5 py-1 rounded-lg ${paperFilter === 'all' ? 'bg-indigo-600 text-white' : 'text-slate-600'}`}
389
+ >
390
+ Tất cả ({digitalPricingKemGiay.length})
391
+ </button>
392
+ <button
393
+ onClick={() => setPaperFilter('C')}
394
+ className={`px-2.5 py-1 rounded-lg ${paperFilter === 'C' ? 'bg-indigo-600 text-white' : 'text-slate-600'}`}
395
+ >
396
+ Couche (6)
397
+ </button>
398
+ <button
399
+ onClick={() => setPaperFilter('I')}
400
+ className={`px-2.5 py-1 rounded-lg ${paperFilter === 'I' ? 'bg-indigo-600 text-white' : 'text-slate-600'}`}
401
+ >
402
+ Ivory (3)
403
+ </button>
404
+ <button
405
+ onClick={() => setPaperFilter('F')}
406
+ className={`px-2.5 py-1 rounded-lg ${paperFilter === 'F' ? 'bg-indigo-600 text-white' : 'text-slate-600'}`}
407
+ >
408
+ Fort (7)
409
+ </button>
410
+ </div>
411
+ </div>
412
+
413
+ {/* Phụ phí */}
414
+ <div className="mt-3 bg-white/80 border border-indigo-200 rounded-xl p-2.5 flex items-start gap-2 text-xs">
415
+ <Info className="w-4 h-4 text-indigo-600 shrink-0 mt-0.5" />
416
+ <div>
417
+ <strong className="text-indigo-900 block font-semibold">Quy định phụ phí số lượng ít:</strong>
418
+ <span className="text-slate-600 text-[11px]">
419
+ Số lượng &lt; 50 tờ: <strong>+30.000đ</strong> | &lt; 100 tờ: <strong>+20.000đ</strong> | &ge; 100 tờ: <strong>Miễn phí phụ phí</strong>
420
+ </span>
421
+ </div>
422
  </div>
423
  </div>
424
+
425
+ <div className="p-5 pt-0 overflow-x-auto">
426
+ <table className="w-full text-xs text-left border-collapse">
427
+ <thead>
428
+ <tr className="bg-slate-100 text-slate-700 font-bold border-b border-slate-200">
429
+ <th className="p-3 text-center w-20">Mã</th>
430
+ <th className="p-3 text-left">Chất liệu & Định lượng</th>
431
+ <th className="p-3 text-center w-32">Khổ in</th>
432
+ <th className="p-3 text-right w-36">In 1 mặt (+ Giấy)</th>
433
+ <th className="p-3 text-right w-36">In 2 mặt (+ Giấy)</th>
434
+ <th className="p-3 text-center w-24">Thao tác</th>
435
+ </tr>
436
+ </thead>
437
+ <tbody className="divide-y divide-slate-100">
438
+ {filteredKemGiay.map((row) => {
439
+ const isEditing = editingKemGiay?.id === row.id;
440
+ const cur = isEditing ? editingKemGiay : row;
441
+
442
+ return (
443
+ <tr key={row.id} className="hover:bg-slate-50/70 transition-colors">
444
+ <td className="p-3 text-center font-bold text-indigo-700">
445
+ <span className="px-2 py-0.5 bg-indigo-50 border border-indigo-200 rounded font-mono">
446
+ {row.paperCode}
447
+ </span>
448
+ </td>
449
+
450
+ <td className="p-3">
451
+ <strong className="text-slate-900 block text-xs">{row.paperName}</strong>
452
+ <span className="text-[11px] text-slate-500 font-medium">Định lượng: {row.gsm} gsm</span>
453
+ </td>
454
+
455
+ <td className="p-3 text-center font-semibold text-slate-700">
456
+ <span className="px-2 py-0.5 bg-slate-100 rounded text-[11px]">
457
+ {row.sheetSize} mm
458
+ </span>
459
+ </td>
460
+
461
+ {/* Đơn giá 1 mặt */}
462
+ <td className="p-3 text-right">
463
+ {isEditing ? (
464
+ <input
465
+ type="number"
466
+ className="w-24 p-1.5 border rounded text-right font-bold text-indigo-700 text-xs"
467
+ value={cur.price1Side}
468
+ onChange={(e) => setEditingKemGiay({ ...cur, price1Side: Number(e.target.value) })}
469
+ />
470
+ ) : (
471
+ <span className="font-bold text-slate-900 text-sm">
472
+ {row.price1Side.toLocaleString('vi-VN')}đ
473
+ </span>
474
+ )}
475
+ </td>
476
+
477
+ {/* Đơn giá 2 mặt */}
478
+ <td className="p-3 text-right">
479
+ {isEditing ? (
480
+ <input
481
+ type="number"
482
+ className="w-24 p-1.5 border rounded text-right font-bold text-purple-700 text-xs"
483
+ value={cur.price2Side}
484
+ onChange={(e) => setEditingKemGiay({ ...cur, price2Side: Number(e.target.value) })}
485
+ />
486
+ ) : (
487
+ <span className="font-bold text-purple-700 text-sm">
488
+ {row.price2Side.toLocaleString('vi-VN')}đ
489
+ </span>
490
+ )}
491
+ </td>
492
+
493
+ {/* Nút thao tác */}
494
+ <td className="p-3 text-center">
495
+ {isEditing ? (
496
+ <div className="flex items-center justify-center gap-1">
497
+ <button
498
+ onClick={() => handleSaveKemGiay(cur)}
499
+ className="p-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded shadow-xs"
500
+ title="Lưu thay đổi"
501
+ >
502
+ <Save className="w-3.5 h-3.5" />
503
+ </button>
504
+ <button
505
+ onClick={() => setEditingKemGiay(null)}
506
+ className="p-1.5 bg-slate-200 hover:bg-slate-300 text-slate-700 rounded"
507
+ title="Hủy"
508
+ >
509
+ <X className="w-3.5 h-3.5" />
510
+ </button>
511
+ </div>
512
+ ) : (
513
+ <button
514
+ onClick={() => setEditingKemGiay({ ...row })}
515
+ className="p-1.5 text-slate-400 hover:text-indigo-600 rounded hover:bg-indigo-50 transition-colors"
516
+ title="Sửa giá"
517
+ >
518
+ <Edit2 className="w-3.5 h-3.5" />
519
+ </button>
520
+ )}
521
+ </td>
522
+ </tr>
523
+ );
524
+ })}
525
+ </tbody>
526
+ </table>
527
+ </div>
528
  </div>
529
+ )}
530
 
531
+ {/* TAB 3: MÁY IN OFFSET */}
532
+ {activeTab === 'offset' && (
533
+ <div className="bg-white rounded-2xl border border-slate-200 shadow-2xs overflow-hidden space-y-4">
534
+ <div className="p-5 border-b border-slate-200 bg-gradient-to-r from-blue-50 to-indigo-50/40">
535
+ <h3 className="font-bold text-slate-900 text-sm flex items-center gap-2">
536
+ <Cpu className="w-5 h-5 text-indigo-600" />
537
+ Cấu Hình Máy In Offset & Gói In Trọn Gói Gồm Kẽm
538
+ </h3>
539
+ <p className="text-xs text-slate-600 mt-0.5">
540
+ Khổ 65 &times; 86 cm (bắt nhíp chiều 86): 1.200.000đ | Khổ nhỏ dưới 65 &times; 43 cm: 900.000đ (Dưới 3.000 lượt in đã bao gồm 4 kẽm)
541
+ </p>
542
+ </div>
543
+
544
+ <div className="p-5 pt-0 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
545
+ {offsetMachines.map((m) => {
546
+ const isEditing = editingOffset?.id === m.id;
547
+ const current = isEditing ? editingOffset : m;
548
+
549
+ return (
550
+ <div key={m.id} className="border border-slate-200 rounded-xl p-4 bg-slate-50/50 flex flex-col justify-between space-y-3">
551
+ <div className="flex items-center justify-between border-b border-slate-200 pb-2">
552
+ <strong className="text-xs font-bold text-slate-900">{current.name}</strong>
553
+ <button
554
+ onClick={() => setEditingOffset(isEditing ? null : { ...m })}
555
+ className="p-1 text-slate-400 hover:text-indigo-600"
556
+ >
557
+ <Edit2 className="w-3.5 h-3.5" />
558
+ </button>
559
+ </div>
560
 
561
+ <div className="space-y-2 text-xs">
562
+ <div className="flex justify-between items-center">
563
+ <span className="text-slate-500">Khổ in max:</span>
564
+ {isEditing ? (
565
+ <div className="flex gap-1 w-32">
566
+ <input
567
+ type="number"
568
+ className="w-1/2 p-1 border rounded text-right"
569
+ value={current.maxWidthMm}
570
+ onChange={(e) => setEditingOffset({ ...current, maxWidthMm: Number(e.target.value) })}
571
+ />
572
+ <input
573
+ type="number"
574
+ className="w-1/2 p-1 border rounded text-right"
575
+ value={current.maxHeightMm}
576
+ onChange={(e) => setEditingOffset({ ...current, maxHeightMm: Number(e.target.value) })}
577
+ />
578
+ </div>
579
+ ) : (
580
+ <span className="font-medium text-slate-800">{current.maxWidthMm} &times; {current.maxHeightMm} mm</span>
581
+ )}
582
+ </div>
583
+
584
+ <div className="flex justify-between items-center">
585
+ <span className="text-slate-500">Giá kẽm CTP / lá:</span>
586
  {isEditing ? (
587
  <input
588
  type="number"
589
+ className="w-28 p-1 border rounded text-right"
590
+ value={current.platePrice}
591
+ onChange={(e) => setEditingOffset({ ...current, platePrice: Number(e.target.value) })}
592
  />
593
  ) : (
594
+ <span className="font-bold text-indigo-700">{current.platePrice.toLocaleString('vi-VN')}đ</span>
595
  )}
596
  </div>
597
+
598
+ <div className="flex justify-between items-center">
599
+ <span className="text-slate-500">Mở máy (&le;{current.baseImpressions || 3000} lượt):</span>
600
  {isEditing ? (
601
  <input
602
  type="number"
603
+ className="w-28 p-1 border rounded text-right"
604
+ value={current.setupCost}
605
+ onChange={(e) => setEditingOffset({ ...current, setupCost: Number(e.target.value) })}
606
  />
607
  ) : (
608
+ <div className="text-right">
609
+ <span className="font-bold text-slate-800">{current.setupCost.toLocaleString('vi-VN')}đ</span>
610
+ {current.includesPlate && (
611
+ <span className="text-[10px] font-semibold text-emerald-600 block">Đã gồm kẽm</span>
612
+ )}
613
+ </div>
614
  )}
615
  </div>
 
616
 
617
+ <div className="flex justify-between items-center">
618
+ <span className="text-slate-500">Lũy tiến / 1.000 lượt:</span>
 
619
  {isEditing ? (
620
  <input
621
  type="number"
622
+ className="w-28 p-1 border rounded text-right"
623
+ value={current.stepCost}
624
+ onChange={(e) => setEditingOffset({ ...current, stepCost: Number(e.target.value) })}
625
  />
626
  ) : (
627
+ <span className="font-medium text-slate-800">{current.stepCost.toLocaleString('vi-VN')}đ</span>
628
  )}
629
  </div>
630
+
631
+ <div className="flex justify-between items-center">
632
+ <span className="text-slate-500">Bù hao kẹp nhíp:</span>
633
  {isEditing ? (
634
  <input
635
  type="number"
636
+ className="w-28 p-1 border rounded text-right"
637
+ value={current.gripperMarginMm || 10}
638
+ onChange={(e) => setEditingOffset({ ...current, gripperMarginMm: Number(e.target.value) })}
639
  />
640
  ) : (
641
+ <span className="font-medium text-slate-800">{current.gripperMarginMm || 10} mm</span>
642
  )}
643
  </div>
 
644
 
645
+ <div className="flex justify-between items-center">
646
+ <span className="text-slate-500">Bù hao giấy in:</span>
647
+ {isEditing ? (
648
+ <input
649
+ type="number"
650
+ className="w-28 p-1 border rounded text-right"
651
+ value={current.defaultWasteSheets}
652
+ onChange={(e) => setEditingOffset({ ...current, defaultWasteSheets: Number(e.target.value) })}
653
+ />
654
+ ) : (
655
+ <span className="font-medium text-amber-700">{current.defaultWasteSheets} tờ</span>
656
+ )}
657
+ </div>
658
  </div>
 
659
 
660
+ {isEditing && (
661
+ <div className="pt-2 border-t border-slate-200 flex justify-end gap-1.5">
662
+ <button
663
+ onClick={() => setEditingOffset(null)}
664
+ className="px-2 py-1 border rounded text-[11px] text-slate-600 hover:bg-slate-100"
665
+ >
666
+ Hủy
667
+ </button>
668
+ <button
669
+ onClick={() => handleSaveOffset(current)}
670
+ className="px-2.5 py-1 bg-indigo-600 text-white rounded text-[11px] font-semibold hover:bg-indigo-700"
671
+ >
672
+ Lưu
673
+ </button>
674
+ </div>
675
+ )}
676
+ </div>
677
+ );
678
+ })}
679
+ </div>
680
  </div>
681
+ )}
682
  </div>
683
  );
684
  };
src/lib/db.ts CHANGED
@@ -71,6 +71,35 @@ function initSchema(db: DatabaseSync) {
71
  updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
72
  );
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  CREATE TABLE IF NOT EXISTS finishing_services (
75
  id INTEGER PRIMARY KEY AUTOINCREMENT,
76
  code TEXT NOT NULL UNIQUE,
@@ -256,10 +285,57 @@ function seedDefaultData(db: DatabaseSync) {
256
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
257
  `);
258
 
259
- insertDigital.run('Máy In Nhanh KTS Konica / Ricoh Pro (Khổ A3+ 33x48)', 488, 330, 1200, 2200, 2400, 4200, 3, 20000);
260
  insertDigital.run('Máy In Nhanh Fuji Xerox Versant 3100 (Cao Cấp)', 660, 330, 1500, 2800, 3000, 5400, 3, 30000);
261
  }
262
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  const finishingCount = db.prepare('SELECT COUNT(*) as cnt FROM finishing_services').get() as { cnt: number };
264
  if (finishingCount.cnt === 0) {
265
  const insertFinishing = db.prepare(`
 
71
  updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
72
  );
73
 
74
+ CREATE TABLE IF NOT EXISTS digital_pricing_giacong (
75
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
76
+ machine_name TEXT NOT NULL,
77
+ sheet_size TEXT NOT NULL UNIQUE,
78
+ width_mm REAL NOT NULL,
79
+ height_mm REAL NOT NULL,
80
+ paper_lt_249 REAL NOT NULL,
81
+ paper_250_349 REAL NOT NULL,
82
+ paper_350_450 REAL NOT NULL,
83
+ decal_paper_plastic REAL NOT NULL,
84
+ decal_clear REAL NOT NULL,
85
+ synthetic_paper REAL NOT NULL,
86
+ pvc_plastic REAL NOT NULL,
87
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
88
+ );
89
+
90
+ CREATE TABLE IF NOT EXISTS digital_pricing_kem_giay (
91
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
92
+ paper_code TEXT NOT NULL UNIQUE,
93
+ paper_name TEXT NOT NULL,
94
+ gsm INTEGER NOT NULL,
95
+ sheet_size TEXT NOT NULL,
96
+ width_mm REAL NOT NULL,
97
+ height_mm REAL NOT NULL,
98
+ price_1side REAL NOT NULL,
99
+ price_2side REAL NOT NULL,
100
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
101
+ );
102
+
103
  CREATE TABLE IF NOT EXISTS finishing_services (
104
  id INTEGER PRIMARY KEY AUTOINCREMENT,
105
  code TEXT NOT NULL UNIQUE,
 
285
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
286
  `);
287
 
288
+ insertDigital.run('Máy In Nhanh Konica C12000 / C12010S (5 Màu - Khổ 33x48)', 488, 330, 800, 1600, 2000, 4000, 3, 20000);
289
  insertDigital.run('Máy In Nhanh Fuji Xerox Versant 3100 (Cao Cấp)', 660, 330, 1500, 2800, 3000, 5400, 3, 30000);
290
  }
291
 
292
+ // Seed digital_pricing_giacong
293
+ const giaCongCount = db.prepare('SELECT COUNT(*) as cnt FROM digital_pricing_giacong').get() as { cnt: number };
294
+ if (giaCongCount.cnt === 0) {
295
+ const jsonPath = path.join(process.cwd(), 'data', 'digital_pricing_giacong.json');
296
+ if (fs.existsSync(jsonPath)) {
297
+ try {
298
+ const rows = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
299
+ const insertGC = db.prepare(`
300
+ INSERT INTO digital_pricing_giacong (machine_name, sheet_size, width_mm, height_mm, paper_lt_249, paper_250_349, paper_350_450, decal_paper_plastic, decal_clear, synthetic_paper, pvc_plastic)
301
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
302
+ `);
303
+ for (const r of rows) {
304
+ insertGC.run(
305
+ r.machine_name, r.sheet_size, r.width_mm, r.height_mm,
306
+ r.paper_lt_249, r.paper_250_349, r.paper_350_450,
307
+ r.decal_paper_plastic, r.decal_clear, r.synthetic_paper, r.pvc_plastic
308
+ );
309
+ }
310
+ } catch (err) {
311
+ console.error('Error seeding digital_pricing_giacong:', err);
312
+ }
313
+ }
314
+ }
315
+
316
+ // Seed digital_pricing_kem_giay
317
+ const kemGiayCount = db.prepare('SELECT COUNT(*) as cnt FROM digital_pricing_kem_giay').get() as { cnt: number };
318
+ if (kemGiayCount.cnt === 0) {
319
+ const jsonPath = path.join(process.cwd(), 'data', 'digital_pricing_kem_giay.json');
320
+ if (fs.existsSync(jsonPath)) {
321
+ try {
322
+ const rows = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
323
+ const insertKG = db.prepare(`
324
+ INSERT INTO digital_pricing_kem_giay (paper_code, paper_name, gsm, sheet_size, width_mm, height_mm, price_1side, price_2side)
325
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
326
+ `);
327
+ for (const r of rows) {
328
+ insertKG.run(
329
+ r.paper_code, r.paper_name, r.gsm, r.sheet_size,
330
+ r.width_mm, r.height_mm, r.price_1side, r.price_2side
331
+ );
332
+ }
333
+ } catch (err) {
334
+ console.error('Error seeding digital_pricing_kem_giay:', err);
335
+ }
336
+ }
337
+ }
338
+
339
  const finishingCount = db.prepare('SELECT COUNT(*) as cnt FROM finishing_services').get() as { cnt: number };
340
  if (finishingCount.cnt === 0) {
341
  const insertFinishing = db.prepare(`
src/lib/engine/costCalculator.ts CHANGED
@@ -3,6 +3,9 @@ import {
3
  CalculationResult,
4
  ComparisonItem,
5
  DigitalMachine,
 
 
 
6
  FinishingCostDetail,
7
  FinishingService,
8
  OffsetMachine,
@@ -10,12 +13,41 @@ import {
10
  } from '@/types';
11
  import { calculateImposition } from './imposition';
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  interface CostCalculatorOptions {
14
  input: CalculationInput;
15
  paperType: PaperType;
16
  offsetMachine?: OffsetMachine;
17
  allOffsetMachines?: OffsetMachine[];
18
  digitalMachine?: DigitalMachine;
 
 
19
  allFinishingServices: FinishingService[];
20
  }
21
 
@@ -48,8 +80,6 @@ export function matchOffsetMachine(
48
  (m) => m.name.includes('65x86') || m.maxWidthMm >= 800 || m.maxHeightMm >= 800
49
  );
50
  if (large) return large;
51
-
52
- return allMachines[0];
53
  }
54
 
55
  // Fallback mặc định chuẩn theo yêu cầu xưởng in
@@ -94,8 +124,12 @@ export function calculatePrintCost({
94
  offsetMachine,
95
  allOffsetMachines,
96
  digitalMachine,
 
 
97
  allFinishingServices,
98
  }: CostCalculatorOptions): CalculationResult {
 
 
99
  // 1. Xác định công nghệ in mục tiêu
100
  let chosenTech: 'offset' | 'digital' = 'offset';
101
  if (input.printTech === 'digital') {
@@ -118,6 +152,7 @@ export function calculatePrintCost({
118
  bleedMm: input.bleedMm ?? 2,
119
  paperType,
120
  printTech: chosenTech,
 
121
  preferredMachineSheet: preferredSheet,
122
  gripperMarginMm: offsetMachine?.gripperMarginMm ?? 10,
123
  });
@@ -140,13 +175,11 @@ export function calculatePrintCost({
140
  // Tính chi phí từng mục gia công
141
  let subtotal = 0;
142
  let formula = '';
143
- const sides = sel.sides || 1;
144
 
145
  if (service.calcType === 'per_m2') {
146
- // Tính theo m2 sản phẩm hoặc m2 tờ in
147
  const areaM2 = ((input.widthMm + (input.bleedMm || 2) * 2) / 1000) *
148
  ((input.heightMm + (input.bleedMm || 2) * 2) / 1000) *
149
- input.quantity * sides;
150
  const rawCost = areaM2 * service.unitPrice;
151
  subtotal = Math.max(service.minPrice, Math.round(rawCost)) + service.setupFee;
152
  formula = `${areaM2.toFixed(2)} m² × ${service.unitPrice.toLocaleString('vi-VN')}đ (tối thiểu ${service.minPrice.toLocaleString('vi-VN')}đ)`;
@@ -209,12 +242,14 @@ export function calculatePrintCost({
209
  // Nếu số tờ mẹ >= 500 (trên 1 ram) áp dụng giá sỉ nguyên ram, nếu < 500 tờ áp dụng giá lẻ dưới ram
210
  const isWholesale = parentSheetsNeeded >= 500;
211
  const effectiveRamPrice = isWholesale ? priceAbove500 : priceBelow500;
212
- const paperCost = Math.round(parentSheetsNeeded * (effectiveRamPrice / 500));
213
 
214
  // 6. Tiền in ấn
215
  let plateCost = 0;
216
  let platesCount = 0;
217
  let printRunCost = 0;
 
 
218
 
219
  if (chosenTech === 'offset') {
220
  const machine = offsetMachine || matchOffsetMachine(imposition.printSheet, allOffsetMachines, input.offsetMachineId);
@@ -248,14 +283,13 @@ export function calculatePrintCost({
248
  // In 2 mặt
249
  if (workType === 'sheetwise') {
250
  // In 2 bài riêng: 2 ca in độc lập, 2 bộ kẽm
251
- const colorsF = input.colorsFront || 4;
252
- const colorsB = input.colorsBack || 4;
253
- platesCount = colorsF + colorsB;
254
 
255
  if (includesPlate) {
256
- const extraColorsF = Math.max(0, colorsF - 4);
257
- const extraColorsB = Math.max(0, colorsB - 4);
258
- plateCost = (extraColorsF + extraColorsB) * machine.platePrice;
259
  } else {
260
  plateCost = platesCount * machine.platePrice;
261
  }
@@ -292,40 +326,162 @@ export function calculatePrintCost({
292
  }
293
  }
294
  } else {
295
- // In nhanh kỹ thuật số
296
- const machine = digitalMachine || {
297
- clickA41Side: 1200,
298
- clickA42Side: 2200,
299
- clickA31Side: 2400,
300
- clickA32Side: 4200,
301
- minCharge: 20000,
302
- };
303
-
304
  const is2Sides = input.printSides === '2_side';
305
- // Đánh giá khổ in của Digital: Nếu khổ in > A4 thì tính click A3, ngược lại click A4
306
- const isA3 = imposition.printSheet.widthMm > 220 || imposition.printSheet.heightMm > 310;
307
-
308
- let clickRate = 0;
309
- if (isA3) {
310
- clickRate = is2Sides ? machine.clickA32Side : machine.clickA31Side;
 
 
 
 
 
 
 
311
  } else {
312
- clickRate = is2Sides ? machine.clickA42Side : machine.clickA41Side;
313
  }
314
 
315
- // Giảm giá theo bậc thang số lượng in nhanh
316
- let volumeDiscountFactor = 1.0;
317
- if (totalPrintSheets > 500) {
318
- volumeDiscountFactor = 0.75; // Giảm 25% cho đơn > 500 tờ A3
319
- } else if (totalPrintSheets > 200) {
320
- volumeDiscountFactor = 0.85; // Giảm 15%
321
- } else if (totalPrintSheets > 50) {
322
- volumeDiscountFactor = 0.92; // Giảm 8%
323
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
 
325
- const calculatedClick = Math.round(totalPrintSheets * clickRate * volumeDiscountFactor);
326
- printRunCost = Math.max(machine.minCharge, calculatedClick);
327
- plateCost = 0;
328
- platesCount = 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  }
330
 
331
  const totalPrintCost = plateCost + printRunCost;
@@ -352,14 +508,14 @@ export function calculatePrintCost({
352
  ...input,
353
  quantity: qty,
354
  printTech: 'digital',
355
- }, paperType, offsetMachine, digitalMachine, allFinishingServices);
356
 
357
  // Tính thử chi phí offset
358
  const offResult = runQuickEstimate({
359
  ...input,
360
  quantity: qty,
361
  printTech: 'offset',
362
- }, paperType, offsetMachine, digitalMachine, allFinishingServices);
363
 
364
  comparisonTable.push({
365
  quantity: qty,
@@ -381,16 +537,16 @@ export function calculatePrintCost({
381
  }
382
 
383
  // Tính lại cho số lượng hiện tại nếu người dùng chọn 'auto'
384
- const curDigital = runQuickEstimate({ ...input, printTech: 'digital' }, paperType, offsetMachine, digitalMachine, allFinishingServices);
385
- const curOffset = runQuickEstimate({ ...input, printTech: 'offset' }, paperType, offsetMachine, digitalMachine, allFinishingServices);
386
 
387
  let recommendationText = '';
388
  const diff = Math.abs(curDigital.finalPrice - curOffset.finalPrice);
389
 
390
  if (curDigital.finalPrice < curOffset.finalPrice) {
391
- recommendationText = `Với số lượng ${input.quantity} sản phẩm, In Nhanh (Kỹ thuật số) rẻ hơn In Offset ${diff.toLocaleString('vi-VN')}đ (${Math.round((diff / curOffset.finalPrice) * 100)}%), không tốn tiền xuất kẽm CTP, lấy ngay trong ngày.`;
392
  } else {
393
- recommendationText = `Với số lượng ${input.quantity} sản phẩm, In Offset rẻ hơn In Nhanh ${diff.toLocaleString('vi-VN')}đ (${Math.round((diff / curDigital.finalPrice) * 100)}%), tối ưu chi phí tối đa cho số lượng công nghiệp lớn.`;
394
  }
395
 
396
  return {
@@ -420,6 +576,8 @@ export function calculatePrintCost({
420
  vatAmount,
421
  finalPrice,
422
  unitPrice,
 
 
423
  },
424
  chosenTech,
425
  recommendation: {
@@ -440,9 +598,12 @@ function runQuickEstimate(
440
  paperType: PaperType,
441
  offsetMachine?: OffsetMachine,
442
  digitalMachine?: DigitalMachine,
443
- allFinishingServices: FinishingService[] = []
 
 
444
  ): { finalPrice: number; unitPrice: number } {
445
  const isDigital = input.printTech === 'digital';
 
446
  const preferredSheet = !isDigital && offsetMachine
447
  ? { widthMm: offsetMachine.maxWidthMm, heightMm: offsetMachine.maxHeightMm, name: offsetMachine.name }
448
  : undefined;
@@ -453,28 +614,42 @@ function runQuickEstimate(
453
  bleedMm: input.bleedMm ?? 2,
454
  paperType,
455
  printTech: input.printTech,
 
456
  preferredMachineSheet: preferredSheet,
457
  });
458
 
459
  const ups = Math.max(1, imposition.upsPerPrintSheet);
460
  const netSheets = Math.ceil(input.quantity / ups);
461
  const printWaste = isDigital ? 4 : (offsetMachine?.defaultWasteSheets ?? 100);
462
- const totalSheets = netSheets + printWaste + 20;
463
 
464
  const cuts = Math.max(1, imposition.cutsPerParentSheet);
465
  const parentSheets = Math.ceil(totalSheets / cuts);
466
  const pAbove = paperType.priceAbove500 || paperType.pricePerRam;
467
  const pBelow = paperType.priceBelow500 || pAbove;
468
  const effectiveRamPrice = parentSheets >= 500 ? pAbove : pBelow;
469
- const paperCost = Math.round(parentSheets * (effectiveRamPrice / 500));
470
 
471
  let printCost = 0;
472
  if (isDigital) {
473
- const isA3 = imposition.printSheet.widthMm > 220 || imposition.printSheet.heightMm > 310;
474
- const rate = isA3
475
- ? (input.printSides === '2_side' ? 4200 : 2400)
476
- : (input.printSides === '2_side' ? 2200 : 1200);
477
- printCost = Math.max(20000, totalSheets * rate);
 
 
 
 
 
 
 
 
 
 
 
 
 
478
  } else {
479
  const machine = offsetMachine || matchOffsetMachine(imposition.printSheet);
480
  const baseImpressions = machine.baseImpressions || 3000;
 
3
  CalculationResult,
4
  ComparisonItem,
5
  DigitalMachine,
6
+ DigitalMode,
7
+ DigitalPrintGiaCong,
8
+ DigitalPrintKemGiay,
9
  FinishingCostDetail,
10
  FinishingService,
11
  OffsetMachine,
 
13
  } from '@/types';
14
  import { calculateImposition } from './imposition';
15
 
16
+ export const DEFAULT_DIGITAL_GIA_CONG: DigitalPrintGiaCong[] = [
17
+ { id: 1, machineName: 'KONICA C12000 / C12010S 5 MÀU', sheetSize: 'A4', widthMm: 210, heightMm: 297, paperLt249: 600, paper250To349: 800, paper350To450: 1000, decalPaperPlastic: 1200, decalClear: 1500, syntheticPaper: 1000, pvcPlastic: 1500 },
18
+ { id: 2, machineName: 'KONICA C12000 / C12010S 5 MÀU', sheetSize: '330x355', widthMm: 330, heightMm: 355, paperLt249: 800, paper250To349: 1000, paper350To450: 1800, decalPaperPlastic: 1500, decalClear: 1700, syntheticPaper: 1800, pvcPlastic: 2000 },
19
+ { id: 3, machineName: 'KONICA C12000 / C12010S 5 MÀU', sheetSize: 'A3', widthMm: 297, heightMm: 420, paperLt249: 1200, paper250To349: 1600, paper350To450: 2100, decalPaperPlastic: 2100, decalClear: 2300, syntheticPaper: 2100, pvcPlastic: 2500 },
20
+ { id: 4, machineName: 'KONICA C12000 / C12010S 5 MÀU', sheetSize: '330x483', widthMm: 330, heightMm: 483, paperLt249: 2000, paper250To349: 2500, paper350To450: 2700, decalPaperPlastic: 2500, decalClear: 2800, syntheticPaper: 2700, pvcPlastic: 3000 },
21
+ { id: 5, machineName: 'KONICA C12000 / C12010S 5 MÀU', sheetSize: '330x1200', widthMm: 330, heightMm: 1200, paperLt249: 4000, paper250To349: 7000, paper350To450: 10000, decalPaperPlastic: 6000, decalClear: 7500, syntheticPaper: 15000, pvcPlastic: 0 },
22
+ ];
23
+
24
+ export const DEFAULT_DIGITAL_KEM_GIAY: DigitalPrintKemGiay[] = [
25
+ { id: 1, paperCode: 'C100', paperName: 'Couche 100gsm', gsm: 100, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1600, price2Side: 2800 },
26
+ { id: 2, paperCode: 'C120', paperName: 'Couche 120gsm', gsm: 120, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1700, price2Side: 2900 },
27
+ { id: 3, paperCode: 'C150', paperName: 'Couche 150gsm', gsm: 150, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1800, price2Side: 3000 },
28
+ { id: 4, paperCode: 'C200', paperName: 'Couche 200gsm', gsm: 200, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1900, price2Side: 3100 },
29
+ { id: 5, paperCode: 'C250', paperName: 'Couche 250gsm', gsm: 250, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2100, price2Side: 3300 },
30
+ { id: 6, paperCode: 'C300', paperName: 'Couche 300gsm', gsm: 300, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2700, price2Side: 4300 },
31
+ { id: 7, paperCode: 'I250', paperName: 'Ivory 250gsm', gsm: 250, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2100, price2Side: 3300 },
32
+ { id: 8, paperCode: 'I300', paperName: 'Ivory 300gsm', gsm: 300, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2600, price2Side: 4200 },
33
+ { id: 9, paperCode: 'I350', paperName: 'Ivory 350gsm', gsm: 350, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 3300, price2Side: 5300 },
34
+ { id: 10, paperCode: 'F100', paperName: 'Fort 100gsm', gsm: 100, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1600, price2Side: 2800 },
35
+ { id: 11, paperCode: 'F120', paperName: 'Fort 120gsm', gsm: 120, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1700, price2Side: 2900 },
36
+ { id: 12, paperCode: 'F180', paperName: 'Fort 180gsm', gsm: 180, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 1900, price2Side: 3100 },
37
+ { id: 13, paperCode: 'F230', paperName: 'Fort 230gsm', gsm: 230, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2100, price2Side: 3300 },
38
+ { id: 14, paperCode: 'F250', paperName: 'Fort 250gsm', gsm: 250, sheetSize: '325x430', widthMm: 325, heightMm: 430, price1Side: 2300, price2Side: 3500 },
39
+ { id: 15, paperCode: 'F180_355', paperName: 'Fort 180gsm (325x355)', gsm: 180, sheetSize: '325x355', widthMm: 325, heightMm: 355, price1Side: 1400, price2Side: 2200 },
40
+ { id: 16, paperCode: 'F230_355', paperName: 'Fort 230gsm (325x355)', gsm: 230, sheetSize: '325x355', widthMm: 325, heightMm: 355, price1Side: 1500, price2Side: 2300 },
41
+ ];
42
+
43
  interface CostCalculatorOptions {
44
  input: CalculationInput;
45
  paperType: PaperType;
46
  offsetMachine?: OffsetMachine;
47
  allOffsetMachines?: OffsetMachine[];
48
  digitalMachine?: DigitalMachine;
49
+ digitalPricingGiaCong?: DigitalPrintGiaCong[];
50
+ digitalPricingKemGiay?: DigitalPrintKemGiay[];
51
  allFinishingServices: FinishingService[];
52
  }
53
 
 
80
  (m) => m.name.includes('65x86') || m.maxWidthMm >= 800 || m.maxHeightMm >= 800
81
  );
82
  if (large) return large;
 
 
83
  }
84
 
85
  // Fallback mặc định chuẩn theo yêu cầu xưởng in
 
124
  offsetMachine,
125
  allOffsetMachines,
126
  digitalMachine,
127
+ digitalPricingGiaCong = DEFAULT_DIGITAL_GIA_CONG,
128
+ digitalPricingKemGiay = DEFAULT_DIGITAL_KEM_GIAY,
129
  allFinishingServices,
130
  }: CostCalculatorOptions): CalculationResult {
131
+ const digitalMode: DigitalMode = input.digitalMode || 'with_paper';
132
+
133
  // 1. Xác định công nghệ in mục tiêu
134
  let chosenTech: 'offset' | 'digital' = 'offset';
135
  if (input.printTech === 'digital') {
 
152
  bleedMm: input.bleedMm ?? 2,
153
  paperType,
154
  printTech: chosenTech,
155
+ digitalMode,
156
  preferredMachineSheet: preferredSheet,
157
  gripperMarginMm: offsetMachine?.gripperMarginMm ?? 10,
158
  });
 
175
  // Tính chi phí từng mục gia công
176
  let subtotal = 0;
177
  let formula = '';
 
178
 
179
  if (service.calcType === 'per_m2') {
 
180
  const areaM2 = ((input.widthMm + (input.bleedMm || 2) * 2) / 1000) *
181
  ((input.heightMm + (input.bleedMm || 2) * 2) / 1000) *
182
+ input.quantity * (sel.sides || 1);
183
  const rawCost = areaM2 * service.unitPrice;
184
  subtotal = Math.max(service.minPrice, Math.round(rawCost)) + service.setupFee;
185
  formula = `${areaM2.toFixed(2)} m² × ${service.unitPrice.toLocaleString('vi-VN')}đ (tối thiểu ${service.minPrice.toLocaleString('vi-VN')}đ)`;
 
242
  // Nếu số tờ mẹ >= 500 (trên 1 ram) áp dụng giá sỉ nguyên ram, nếu < 500 tờ áp dụng giá lẻ dưới ram
243
  const isWholesale = parentSheetsNeeded >= 500;
244
  const effectiveRamPrice = isWholesale ? priceAbove500 : priceBelow500;
245
+ let paperCost = Math.round(parentSheetsNeeded * (effectiveRamPrice / 500));
246
 
247
  // 6. Tiền in ấn
248
  let plateCost = 0;
249
  let platesCount = 0;
250
  let printRunCost = 0;
251
+ let shortRunFee = 0;
252
+ let digitalDetails: CalculationResult['costs']['digitalDetails'] | undefined;
253
 
254
  if (chosenTech === 'offset') {
255
  const machine = offsetMachine || matchOffsetMachine(imposition.printSheet, allOffsetMachines, input.offsetMachineId);
 
283
  // In 2 mặt
284
  if (workType === 'sheetwise') {
285
  // In 2 bài riêng: 2 ca in độc lập, 2 bộ kẽm
286
+ const colors = (input.colorsFront || 4) + (input.colorsBack || 4);
287
+ platesCount = colors;
 
288
 
289
  if (includesPlate) {
290
+ // Gói mở máy đã bao gồm 1 bộ kẽm 4 màu cơ bản, bài thứ 2 hoặc màu thứ 5+ tính riêng kẽm
291
+ const extraColors = Math.max(0, colors - 4);
292
+ plateCost = extraColors * machine.platePrice;
293
  } else {
294
  plateCost = platesCount * machine.platePrice;
295
  }
 
326
  }
327
  }
328
  } else {
329
+ // In nhanh kỹ thuật số (Digital)
 
 
 
 
 
 
 
 
330
  const is2Sides = input.printSides === '2_side';
331
+ let ratePerSheet = 0;
332
+ let machineName = '';
333
+ let sheetSizeName = imposition.printSheet.name;
334
+ let whiteInkCost = 0;
335
+
336
+ // Phụ phí số lượng ít:
337
+ // < 50 tờ: +30.000đ
338
+ // < 100 tờ: +20.000đ
339
+ // >= 100 tờ: 0đ
340
+ if (totalPrintSheets < 50) {
341
+ shortRunFee = 30000;
342
+ } else if (totalPrintSheets < 100) {
343
+ shortRunFee = 20000;
344
  } else {
345
+ shortRunFee = 0;
346
  }
347
 
348
+ if (digitalMode === 'with_paper') {
349
+ // 1. In nhanh kèm giấy (INTC) - Đã gồm tiền giấy trọn gói
350
+ machineName = 'In Nhanh Kèm Giấy (INTC)';
351
+ const kemGiayList = digitalPricingKemGiay && digitalPricingKemGiay.length > 0
352
+ ? digitalPricingKemGiay
353
+ : DEFAULT_DIGITAL_KEM_GIAY;
354
+
355
+ // Tìm dòng giá phù hợp theo mã giấy
356
+ let matchedPricing = kemGiayList.find((kg) =>
357
+ paperType.code.toUpperCase().includes(kg.paperCode.toUpperCase())
358
+ );
359
+
360
+ // Nếu không khớp chính xác mã, so khớp theo tiền tố chất liệu (C, I, F) + GSM gần nhất
361
+ if (!matchedPricing) {
362
+ const pName = paperType.name.toLowerCase();
363
+ let prefix = 'C';
364
+ if (pName.includes('ivory') || paperType.code.toUpperCase().startsWith('I')) prefix = 'I';
365
+ else if (pName.includes('fort') || pName.includes('ford') || paperType.code.toUpperCase().startsWith('F')) prefix = 'F';
366
+
367
+ const sameType = kemGiayList.filter((kg) => kg.paperCode.startsWith(prefix));
368
+ if (sameType.length > 0) {
369
+ matchedPricing = sameType.reduce((prev, curr) =>
370
+ Math.abs(curr.gsm - paperType.gsm) < Math.abs(prev.gsm - paperType.gsm) ? curr : prev
371
+ );
372
+ } else {
373
+ matchedPricing = kemGiayList[0];
374
+ }
375
+ }
376
+
377
+ if (matchedPricing) {
378
+ ratePerSheet = is2Sides ? matchedPricing.price2Side : matchedPricing.price1Side;
379
+ sheetSizeName = `${matchedPricing.sheetSize} mm (${matchedPricing.paperName})`;
380
+ } else {
381
+ ratePerSheet = is2Sides ? 3000 : 1800;
382
+ }
383
+
384
+ printRunCost = Math.round(totalPrintSheets * ratePerSheet) + shortRunFee;
385
+
386
+ // Đã kèm giấy nên tiền giấy = 0
387
+ paperCost = 0;
388
+ plateCost = 0;
389
+ platesCount = 0;
390
+
391
+ digitalDetails = {
392
+ mode: 'with_paper',
393
+ machineName,
394
+ sheetSizeName,
395
+ ratePerSheet,
396
+ shortRunFee,
397
+ note: `Bảng giá In Nhanh Kèm Giấy INTC (Đã gồm công in + giấy ${matchedPricing?.paperName || ''}). Phí SL ít: +${shortRunFee.toLocaleString('vi-VN')}đ`,
398
+ };
399
+ } else {
400
+ // 2. In nhanh gia công (Konica C12000 / C12010S 5 màu - Trang 2 PDF)
401
+ machineName = 'Konica C12000 / C12010S 5 Màu';
402
+ const giaCongList = digitalPricingGiaCong && digitalPricingGiaCong.length > 0
403
+ ? digitalPricingGiaCong
404
+ : DEFAULT_DIGITAL_GIA_CONG;
405
+
406
+ // Nhận diện khổ in máy
407
+ const pW = imposition.printSheet.widthMm;
408
+ const pH = imposition.printSheet.heightMm;
409
+ const maxDim = Math.max(pW, pH);
410
+ const minDim = Math.min(pW, pH);
411
+
412
+ let sheetRow: DigitalPrintGiaCong | undefined;
413
+ if (maxDim > 500) {
414
+ sheetRow = giaCongList.find((g) => g.sheetSize === '330x1200');
415
+ } else if (maxDim > 430 || minDim > 300) {
416
+ sheetRow = giaCongList.find((g) => g.sheetSize === '330x483');
417
+ } else if (maxDim > 360 || minDim > 250) {
418
+ sheetRow = giaCongList.find((g) => g.sheetSize === 'A3');
419
+ } else if (maxDim > 300) {
420
+ sheetRow = giaCongList.find((g) => g.sheetSize === '330x355');
421
+ } else {
422
+ sheetRow = giaCongList.find((g) => g.sheetSize === 'A4');
423
+ }
424
+
425
+ if (!sheetRow) {
426
+ sheetRow = giaCongList.find((g) => g.sheetSize === '330x483') || giaCongList[0];
427
+ }
428
+
429
+ // Nhận diện nhóm chất liệu
430
+ const pName = paperType.name.toLowerCase();
431
+ let click1Side = 0;
432
+
433
+ if (sheetRow) {
434
+ if (pName.includes('pvc')) {
435
+ click1Side = sheetRow.pvcPlastic || sheetRow.paper350To450;
436
+ } else if (pName.includes('nhựa') || pName.includes('synthetic')) {
437
+ click1Side = sheetRow.syntheticPaper;
438
+ } else if (pName.includes('trong') && pName.includes('decal')) {
439
+ click1Side = sheetRow.decalClear;
440
+ } else if (pName.includes('decal')) {
441
+ click1Side = sheetRow.decalPaperPlastic;
442
+ } else {
443
+ // Giấy thường theo định lượng GSM
444
+ if (paperType.gsm < 250) {
445
+ click1Side = sheetRow.paperLt249;
446
+ } else if (paperType.gsm <= 349) {
447
+ click1Side = sheetRow.paper250To349;
448
+ } else {
449
+ click1Side = sheetRow.paper350To450;
450
+ }
451
+ }
452
+ sheetSizeName = `${sheetRow.sheetSize} (${sheetRow.widthMm}x${sheetRow.heightMm}mm)`;
453
+ } else {
454
+ click1Side = is2Sides ? 2000 : 1000;
455
+ }
456
 
457
+ // Giá in 2 mặt = x2 giá 1 mặt
458
+ ratePerSheet = is2Sides ? (click1Side * 2) : click1Side;
459
+
460
+ // In mực trắng nếu có tùy chọn
461
+ if (input.whiteInk) {
462
+ whiteInkCost = totalPrintSheets * (is2Sides ? 4000 : 2000);
463
+ }
464
+
465
+ printRunCost = Math.round(totalPrintSheets * ratePerSheet) + whiteInkCost + shortRunFee;
466
+
467
+ // Nếu khách tự cấp giấy -> tiền giấy = 0
468
+ if (input.customerSuppliedPaper) {
469
+ paperCost = 0;
470
+ }
471
+
472
+ plateCost = 0;
473
+ platesCount = 0;
474
+
475
+ digitalDetails = {
476
+ mode: 'without_paper',
477
+ machineName,
478
+ sheetSizeName,
479
+ ratePerSheet,
480
+ shortRunFee,
481
+ whiteInkCost,
482
+ note: `In Gia Công Konica C12000 (Khổ ${sheetSizeName}, Đơn giá ${ratePerSheet.toLocaleString('vi-VN')}đ/tờ). Phí SL ít: +${shortRunFee.toLocaleString('vi-VN')}đ${input.customerSuppliedPaper ? ' • Khách cấp giấy' : ''}`,
483
+ };
484
+ }
485
  }
486
 
487
  const totalPrintCost = plateCost + printRunCost;
 
508
  ...input,
509
  quantity: qty,
510
  printTech: 'digital',
511
+ }, paperType, offsetMachine, digitalMachine, allFinishingServices, digitalPricingGiaCong, digitalPricingKemGiay);
512
 
513
  // Tính thử chi phí offset
514
  const offResult = runQuickEstimate({
515
  ...input,
516
  quantity: qty,
517
  printTech: 'offset',
518
+ }, paperType, offsetMachine, digitalMachine, allFinishingServices, digitalPricingGiaCong, digitalPricingKemGiay);
519
 
520
  comparisonTable.push({
521
  quantity: qty,
 
537
  }
538
 
539
  // Tính lại cho số lượng hiện tại nếu người dùng chọn 'auto'
540
+ const curDigital = runQuickEstimate({ ...input, printTech: 'digital' }, paperType, offsetMachine, digitalMachine, allFinishingServices, digitalPricingGiaCong, digitalPricingKemGiay);
541
+ const curOffset = runQuickEstimate({ ...input, printTech: 'offset' }, paperType, offsetMachine, digitalMachine, allFinishingServices, digitalPricingGiaCong, digitalPricingKemGiay);
542
 
543
  let recommendationText = '';
544
  const diff = Math.abs(curDigital.finalPrice - curOffset.finalPrice);
545
 
546
  if (curDigital.finalPrice < curOffset.finalPrice) {
547
+ recommendationText = `Với số lượng ${input.quantity.toLocaleString('vi-VN')} sản phẩm, In Nhanh KTS tiết kiệm hơn In Offset ${diff.toLocaleString('vi-VN')}đ (${Math.round((diff / curOffset.finalPrice) * 100)}%), không tốn phí mở máy và kẽm CTP, lấy nhanh trong ngày.`;
548
  } else {
549
+ recommendationText = `Với số lượng ${input.quantity.toLocaleString('vi-VN')} sản phẩm, In Offset tối ưu chi phí hơn In Nhanh ${diff.toLocaleString('vi-VN')}đ (${Math.round((diff / curDigital.finalPrice) * 100)}%), giá đơn vị giảm sâu theo số lượng lớn.`;
550
  }
551
 
552
  return {
 
576
  vatAmount,
577
  finalPrice,
578
  unitPrice,
579
+ shortRunFee,
580
+ digitalDetails,
581
  },
582
  chosenTech,
583
  recommendation: {
 
598
  paperType: PaperType,
599
  offsetMachine?: OffsetMachine,
600
  digitalMachine?: DigitalMachine,
601
+ allFinishingServices: FinishingService[] = [],
602
+ digitalPricingGiaCong: DigitalPrintGiaCong[] = DEFAULT_DIGITAL_GIA_CONG,
603
+ digitalPricingKemGiay: DigitalPrintKemGiay[] = DEFAULT_DIGITAL_KEM_GIAY
604
  ): { finalPrice: number; unitPrice: number } {
605
  const isDigital = input.printTech === 'digital';
606
+ const digitalMode: DigitalMode = input.digitalMode || 'with_paper';
607
  const preferredSheet = !isDigital && offsetMachine
608
  ? { widthMm: offsetMachine.maxWidthMm, heightMm: offsetMachine.maxHeightMm, name: offsetMachine.name }
609
  : undefined;
 
614
  bleedMm: input.bleedMm ?? 2,
615
  paperType,
616
  printTech: input.printTech,
617
+ digitalMode,
618
  preferredMachineSheet: preferredSheet,
619
  });
620
 
621
  const ups = Math.max(1, imposition.upsPerPrintSheet);
622
  const netSheets = Math.ceil(input.quantity / ups);
623
  const printWaste = isDigital ? 4 : (offsetMachine?.defaultWasteSheets ?? 100);
624
+ const totalSheets = netSheets + printWaste + 10;
625
 
626
  const cuts = Math.max(1, imposition.cutsPerParentSheet);
627
  const parentSheets = Math.ceil(totalSheets / cuts);
628
  const pAbove = paperType.priceAbove500 || paperType.pricePerRam;
629
  const pBelow = paperType.priceBelow500 || pAbove;
630
  const effectiveRamPrice = parentSheets >= 500 ? pAbove : pBelow;
631
+ let paperCost = Math.round(parentSheets * (effectiveRamPrice / 500));
632
 
633
  let printCost = 0;
634
  if (isDigital) {
635
+ const is2Sides = input.printSides === '2_side';
636
+ let shortFee = 0;
637
+ if (totalSheets < 50) shortFee = 30000;
638
+ else if (totalSheets < 100) shortFee = 20000;
639
+
640
+ if (digitalMode === 'with_paper') {
641
+ const matched = digitalPricingKemGiay.find((kg) =>
642
+ paperType.code.toUpperCase().includes(kg.paperCode.toUpperCase())
643
+ ) || digitalPricingKemGiay[0];
644
+ const rate = is2Sides ? matched.price2Side : matched.price1Side;
645
+ printCost = Math.round(totalSheets * rate) + shortFee;
646
+ paperCost = 0;
647
+ } else {
648
+ const isA3 = imposition.printSheet.widthMm > 220 || imposition.printSheet.heightMm > 310;
649
+ const rate = isA3 ? (is2Sides ? 4000 : 2000) : (is2Sides ? 1600 : 800);
650
+ printCost = Math.round(totalSheets * rate) + shortFee;
651
+ if (input.customerSuppliedPaper) paperCost = 0;
652
+ }
653
  } else {
654
  const machine = offsetMachine || matchOffsetMachine(imposition.printSheet);
655
  const baseImpressions = machine.baseImpressions || 3000;
src/lib/engine/imposition.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { BoxCoordinate, ImpositionResult, PaperCutBlock, PaperCutLine, PaperCutScheme, PaperType, PrintTech } from '@/types';
2
 
3
  interface ImpositionOptions {
4
  widthMm: number;
@@ -6,6 +6,7 @@ interface ImpositionOptions {
6
  bleedMm?: number;
7
  paperType: PaperType;
8
  printTech: PrintTech;
 
9
  preferredMachineSheet?: { widthMm: number; heightMm: number; name: string };
10
  gripperMarginMm?: number;
11
  }
@@ -25,6 +26,7 @@ export function calculateImposition({
25
  bleedMm = 2,
26
  paperType,
27
  printTech,
 
28
  preferredMachineSheet,
29
  gripperMarginMm = 10,
30
  }: ImpositionOptions): ImpositionResult {
@@ -47,12 +49,26 @@ export function calculateImposition({
47
  gripperMarginMm,
48
  });
49
  } else if (printTech === 'digital') {
50
- // In nhanh KTS: các khổ in máy tiêu chuẩn A3+, A3, A4
51
- const digitalCandidates = [
52
- { name: 'Khổ A3+ (330 x 488 mm)', w: 488, h: 330, cuts: 4 },
53
- { name: 'Khổ A3 chuẩn (297 x 420 mm)', w: 420, h: 297, cuts: 4 },
54
- { name: 'Khổ A4 chuẩn (210 x 297 mm)', w: 297, h: 210, cuts: 8 },
55
- ];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
  for (const dc of digitalCandidates) {
58
  const cuts = calculateCutsFromParent(parentW, parentH, dc.w, dc.h);
@@ -63,7 +79,7 @@ export function calculateImposition({
63
  sheetHeightMm: dc.h,
64
  cutsFromParent: cuts,
65
  cutScheme,
66
- gripperMarginMm: 5,
67
  });
68
  }
69
  } else {
 
1
+ import { BoxCoordinate, DigitalMode, ImpositionResult, PaperCutBlock, PaperCutLine, PaperCutScheme, PaperType, PrintTech } from '@/types';
2
 
3
  interface ImpositionOptions {
4
  widthMm: number;
 
6
  bleedMm?: number;
7
  paperType: PaperType;
8
  printTech: PrintTech;
9
+ digitalMode?: DigitalMode;
10
  preferredMachineSheet?: { widthMm: number; heightMm: number; name: string };
11
  gripperMarginMm?: number;
12
  }
 
26
  bleedMm = 2,
27
  paperType,
28
  printTech,
29
+ digitalMode = 'with_paper',
30
  preferredMachineSheet,
31
  gripperMarginMm = 10,
32
  }: ImpositionOptions): ImpositionResult {
 
49
  gripperMarginMm,
50
  });
51
  } else if (printTech === 'digital') {
52
+ // In nhanh KTS: Theo chuẩn Bảng Giá In Nhanh Kèm Giấy & In Gia Công Konica C12000
53
+ let digitalCandidates: { name: string; w: number; h: number; cuts: number }[] = [];
54
+
55
+ if (digitalMode === 'with_paper') {
56
+ // BẢNG GIÁ IN NHANH KÈM GIẤY: Khổ 325 x 430 mm (chính) & 325 x 355 mm (Fort)
57
+ digitalCandidates = [
58
+ { name: 'Khổ in kèm giấy (325 x 430 mm)', w: 430, h: 325, cuts: 4 },
59
+ { name: 'Khổ in kèm giấy đứng (325 x 430 mm)', w: 325, h: 430, cuts: 4 },
60
+ { name: 'Khổ in kèm giấy (325 x 355 mm)', w: 355, h: 325, cuts: 4 },
61
+ ];
62
+ } else {
63
+ // BẢNG GIÁ IN GIA CÔNG KONICA C12000 / C12010S: 5 khổ in máy
64
+ digitalCandidates = [
65
+ { name: 'Khổ Konica A3+ (330 x 483 mm)', w: 483, h: 330, cuts: 4 },
66
+ { name: 'Khổ Konica A3 chuẩn (297 x 420 mm)', w: 420, h: 297, cuts: 4 },
67
+ { name: 'Khổ Konica 330 x 355 mm', w: 355, h: 330, cuts: 4 },
68
+ { name: 'Khổ Konica A4 chuẩn (210 x 297 mm)', w: 297, h: 210, cuts: 8 },
69
+ { name: 'Khổ Konica Banner (330 x 1200 mm)', w: 1200, h: 330, cuts: 1 },
70
+ ];
71
+ }
72
 
73
  for (const dc of digitalCandidates) {
74
  const cuts = calculateCutsFromParent(parentW, parentH, dc.w, dc.h);
 
79
  sheetHeightMm: dc.h,
80
  cutsFromParent: cuts,
81
  cutScheme,
82
+ gripperMarginMm: 4,
83
  });
84
  }
85
  } else {
src/types/index.ts CHANGED
@@ -36,6 +36,35 @@ export interface OffsetMachine {
36
  includesPlate?: boolean; // Đã bao gồm kẽm CTP trong phí mở máy
37
  }
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  export interface DigitalMachine {
40
  id: number;
41
  name: string;
@@ -98,6 +127,9 @@ export interface CalculationInput {
98
  offsetWorkType?: OffsetWorkType;
99
  offsetMachineId?: number;
100
  digitalMachineId?: number;
 
 
 
101
  selectedFinishing: SelectedFinishing[];
102
  profitMarginPercent: number;
103
  discountAmount?: number;
@@ -221,6 +253,16 @@ export interface CalculationResult {
221
  vatAmount: number;
222
  finalPrice: number;
223
  unitPrice: number;
 
 
 
 
 
 
 
 
 
 
224
  };
225
  chosenTech: 'offset' | 'digital';
226
  recommendation?: {
 
36
  includesPlate?: boolean; // Đã bao gồm kẽm CTP trong phí mở máy
37
  }
38
 
39
+ export type DigitalMode = 'with_paper' | 'without_paper'; // In nhanh kèm giấy hoặc In gia công không kèm giấy
40
+
41
+ export interface DigitalPrintGiaCong {
42
+ id: number;
43
+ machineName: string;
44
+ sheetSize: string; // 'A4' | '330x355' | 'A3' | '330x483' | '330x1200'
45
+ widthMm: number;
46
+ heightMm: number;
47
+ paperLt249: number; // Giấy (<249gsm)
48
+ paper250To349: number; // Giấy (250gsm - 349gsm)
49
+ paper350To450: number; // Giấy (350gsm - 450gsm)
50
+ decalPaperPlastic: number; // Decal (giấy - nhựa)
51
+ decalClear: number; // Decal trong
52
+ syntheticPaper: number; // Giấy nhựa (<280gsm)
53
+ pvcPlastic: number; // Nhựa PVC
54
+ }
55
+
56
+ export interface DigitalPrintKemGiay {
57
+ id: number;
58
+ paperCode: string;
59
+ paperName: string;
60
+ gsm: number;
61
+ sheetSize: string; // '325x430' | '325x355'
62
+ widthMm: number;
63
+ heightMm: number;
64
+ price1Side: number; // In 1 mặt + giấy
65
+ price2Side: number; // In 2 mặt + giấy
66
+ }
67
+
68
  export interface DigitalMachine {
69
  id: number;
70
  name: string;
 
127
  offsetWorkType?: OffsetWorkType;
128
  offsetMachineId?: number;
129
  digitalMachineId?: number;
130
+ digitalMode?: DigitalMode; // 'with_paper' (Kèm giấy xưởng) | 'without_paper' (In gia công Konica)
131
+ customerSuppliedPaper?: boolean; // Nếu in gia công mà khách mang giấy đến -> tiền giấy = 0
132
+ whiteInk?: boolean; // Tùy chọn in mực trắng (Konica C12000)
133
  selectedFinishing: SelectedFinishing[];
134
  profitMarginPercent: number;
135
  discountAmount?: number;
 
253
  vatAmount: number;
254
  finalPrice: number;
255
  unitPrice: number;
256
+ shortRunFee?: number; // Phí số lượng ít (<50 tờ: +30k, <100 tờ: +20k)
257
+ digitalDetails?: {
258
+ mode: DigitalMode;
259
+ machineName?: string;
260
+ sheetSizeName?: string;
261
+ ratePerSheet: number;
262
+ shortRunFee: number;
263
+ whiteInkCost?: number;
264
+ note?: string;
265
+ };
266
  };
267
  chosenTech: 'offset' | 'digital';
268
  recommendation?: {