diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a31842 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# Sheet service + +Sheet controller by grist + +## PUBLISH sheet-service/catalogs + +``` +PUBLISH sheet-service/catalogs '{"type": "sheet","payload": {"user_info": {"uid": "v5KnPgqs1Ed216cCOmj19mUSzS02"},"srv_name": "sheet-service","values": {"country": "tha"}}}' +``` + +## PUBLISH sheet-service/enter + +``` +PUBLISH sheet-service/enter '{"type": "sheet","payload": {"user_info": {"uid": "v5KnPgqs1Ed216cCOmj19mUSzS02"},"srv_name": "sheet-service","values": {"country": "tha","catalog":"page_catalog_group_coffee.skt"}}}' +``` + +## PUBLISH sheet-service/heartbeat + +``` +PUBLISH sheet-service/heartbeat '{"type": "sheet","payload": {"user_info": {"uid": "v5KnPgqs1Ed216cCOmj19mUSzS02"},"srv_name": "sheet-service","values": {"country": "tha","catalog":"page_catalog_group_coffee.skt"}}}' +``` + +## PUBLISH sheet-service/exit + +``` +PUBLISH sheet-service/exit '{"type": "sheet","payload": {"user_info": {"user_id": "v5KnPgqs1Ed216cCOmj19mUSzS02"},"srv_name": "sheet-service","values": {"country": "tha"}}}' +``` + +## PUBLISH sheet-service/catalog/menu + +``` +PUBLISH sheet-service/catalog/menu '{"type": "sheet","payload": {"user_info": {"uid": "v5KnPgqs1Ed216cCOmj19mUSzS02"},"srv_name": "sheet-service","values": {"country": "tha","catalog":"page_catalog_group_coffee.skt"}}}' +``` + +## PUBLISH sheet-service/add/catalog + +``` +PUBLISH sheet-service/add/catalog '{"type": "sheet","payload": {"user_info": {"uid": "v5KnPgqs1Ed216cCOmj19mUSzS02"},"srv_name": "sheet-service","values": {"country": "tha","catalog_name":"Coffee","catalog":"page_catalog_group_coffee9.skt"}}}' +``` + +## PUBLISH sheet-service/add/menu + +``` +PUBLISH sheet-service/add/menu '{"type": "sheet","payload": {"user_info": {"uid": "v5KnPgqs1Ed216cCOmj19mUSzS02"},"srv_name": "sheet-service","values": {"country": "tha","catalog":"page_catalog_group_coffee.skt","content": [{"cells":["", "", "ซูปราาาาาแอพพพพ", "Supra menuuu", "กาแฟ และน้ำ ", "Espresso, Water", "99-01-01-0003", "99-01-02-0001", "99-01-03-0001", "bn_hot_america_no.png", "99-99-01-0003", "99-99-02-0001", "-", "posi1", "-", "-", "-", "-", "Coffee,CoffeeNoMilk,ShakeShake", ""], "payload": {"lang_name":["冰镇能量饮料\n加量苏打","-","-",""], "lang_desc": ["","-","-",""]}},{"cells":["", "", "new supar menuuu", "Supra menuuu", "กาแฟ และน้ำ ", "Espresso, Water", "99-99-01-0003", "99-99-02-0001", "99-99-03-0001", "bn_hot_america_no.png", "99-99-01-0003", "99-99-02-0001", "99-99-03-0003", "posi1", "-", "-", "-", "-", "Coffee,CoffeeNoMilk,ShakeShake", ""], "payload": {"lang_name":["冰镇能量饮料\n加量苏打","-","-",""], "lang_desc": ["","-","-",""]}}]}}}' +``` + +## PUBLISH sheet-service/update/menu + +``` +PUBLISH sheet-service/update/menu '{"type": "sheet","payload": {"user_info": {"uid": "v5KnPgqs1Ed216cCOmj19mUSzS02"},"srv_name": "sheet-service","values": {"country": "tha","catalog": "page_catalog_group_coffee.skt","content": [{"new_layout_v2": [{"row_index": 1, "cells": [{"value": "", "coord": {"row": 1, "col": 1}}, {"value": "name", "coord": {"row": 1, "col": 2}}, {"value": "Americano", "coord": {"row": 1, "col": 3}}, {"value": "อเมริกาโน test", "coord": {"row": 1, "col": 4}}, {"value": "美式咖啡 จีน", "coord": {"row": 1, "col": 5}}, {"value": "-", "coord": {"row": 1, "col": 6}}, {"value": "-", "coord": {"row": 1, "col": 7}}, {"value": "အမေရိကာနို พม่า", "coord": {"row": 1, "col": 8}}, {"value": "12-01-01-0003,12-21-01-0003", "coord": {"row": 1, "col": 9}}, {"value": "12-01-02-0001,12-21-02-0001", "coord": {"row": 1, "col": 10}}, {"value": "แท็ก Coffee,CoffeeNoMilk", "coord": {"row": 1, "col": 23}}]}, {"row_index": 2, "cells": [{"value": "", "coord": {"row": 2, "col": 1}}, {"value": "desc", "coord": {"row": 2, "col": 2}}, {"value": "Espresso, Water", "coord": {"row": 2, "col": 3}}, {"value": "กาแฟ และน้ำ sure? ", "coord": {"row": 2, "col": 4}}, {"value": "จีนเมกาโน่ 浓缩咖啡、水", "coord": {"row": 2, "col": 5}}]}, {"row_index": 3, "cells": [{"value": "", "coord": {"row": 3, "col": 1}}, {"value": "img", "coord": {"row": 3, "col": 2}}, {"value": "bn_hot_america_no.png", "coord": {"row": 3, "col": 3}}, {"value": "posi1 เทส", "coord": {"row": 3, "col": 9}}]}, {"row_index": 4, "cells": []}], "name_desc_v2": [{"key": "MENU.12-21-01-0003.name", "row_index": 3, "cells": [{"value": "จีนน 热美式咖啡", "coord": {"row": 3, "col": 5}}, {"value": "-", "coord": {"row": 3, "col": 6}}, {"value": "-", "coord": {"row": 3, "col": 7}}, {"value": "พม่าา ဟော့အမေရိကန်နို", "coord": {"row": 3, "col": 8}}]}, {"key": "MENU.12-21-01-0003.desc", "row_index": 4, "cells": [{"value": "จีนนน 浓缩咖啡、水", "coord": {"row": 4, "col": 5}}, {"value": "-", "coord": {"row": 4, "col": 6}}, {"value": "-", "coord": {"row": 4, "col": 7}}, {"value": "พม่า Espresso၊ ရေ", "coord": {"row": 4, "col": 8}}]}, {"key": "MENU.12-21-02-0001.name", "row_index": 7, "cells": [{"value": "จนน 热美式咖啡", "coord": {"row": 7, "col": 5}}, {"value": "-", "coord": {"row": 7, "col": 6}}, {"value": "-", "coord": {"row": 7, "col": 7}}, {"value": "พมาาဟော့အမေရိကန်နို", "coord": {"row": 7, "col": 8}}]}, {"key": "MENU.12-21-02-0001.desc", "row_index": 8, "cells": [{"value": "จันน浓缩咖啡、水", "coord": {"row": 8, "col": 5}}, {"value": "-", "coord": {"row": 8, "col": 6}}, {"value": "-", "coord": {"row": 8, "col": 7}}, {"value": "myanmar Espresso၊ ရေ", "coord": {"row": 8, "col": 8}}]}, {"key": "MENU.12-01-01-0003.name", "row_index": 1, "cells": [{"value": "myanmar 热美式咖啡", "coord": {"row": 1, "col": 5}}, {"value": "-", "coord": {"row": 1, "col": 6}}, {"value": "-", "coord": {"row": 1, "col": 7}}, {"value": "myanmar ဟော့အမေရိကန်နို", "coord": {"row": 1, "col": 8}}]}]}]}}}' +``` + +## PUBLISH sheet-service/delete/menu + +``` +PUBLISH sheet-service/delete/menu '{"type": "sheet","payload": {"user_info": {"uid": "v5KnPgqs1Ed216cCOmj19mUSzS02"},"srv_name": "sheet-service","values": {"country": "tha","catalog":"page_catalog_group_coffee.skt","content": [{"target_id":389}, {"target_id":393}]}}}' +``` + +## PUBLISH sheet-service/swap/menu + +``` +PUBLISH sheet-service/swap/menu '{"type": "sheet","payload": {"user_info": {"uid": "v5KnPgqs1Ed216cCOmj19mUSzS02"},"srv_name": "sheet-service","values": {"country": "tha","catalog":"page_catalog_group_coffee.skt","content": [{ "target_id": 1, "source_id": 9 },{ "target_id": 5, "source_id": 1 },{ "target_id": 9, "source_id": 5 }]}}}' +``` \ No newline at end of file diff --git a/main.py b/main.py index eb6471c..965982f 100644 --- a/main.py +++ b/main.py @@ -26,10 +26,12 @@ HEARTBEAT_CHANNEL = f"{SERVICE_NAME}/heartbeat" EXIT_CHANNEL = f"{SERVICE_NAME}/exit" GET_CATALOG_CHANNEL = f"{SERVICE_NAME}/catalogs" +GET_MENU_CATALOG_CHANNEL = f"{SERVICE_NAME}/catalog/menu" UPDATE_MENU_CHANNEL = f"{SERVICE_NAME}/update/menu" DELETE_MENU_CHANNEL = f"{SERVICE_NAME}/delete/menu" ADD_CATALOG_CHANNEL = f"{SERVICE_NAME}/add/catalog" ADD_MENU_CHANNEL = f"{SERVICE_NAME}/add/menu" +SWAP_MENU_CHANNEL = f"{SERVICE_NAME}/swap/menu" # Grist set up ... GRIST_URL = os.getenv("GRIST_URL") @@ -37,27 +39,54 @@ GRIST_API_KEY = os.getenv("GRIST_API_KEY") r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True) -ID_PATTERN = r'[a-zA-Z0-9]{2}-[a-zA-Z0-9]{2}-[a-zA-Z0-9]{2}-[a-zA-Z0-9]{4}' +# ID_PATTERN = r'[a-zA-Z0-9]{2}-[a-zA-Z0-9]{2}-[a-zA-Z0-9]{2}-[a-zA-Z0-9]{4}' COUNTRY_MAPPING = { "tha": { "spreadsheet_id": os.getenv("SPREAD_SHEET_ID_THA"), - "sheets": [ - os.getenv("SHEET_NEW_LAYOUT_THA"), - os.getenv("SHEET_NEW_LAYOUT_V2_THA"), - os.getenv("SHEET_NAME_DESC_V2_THA") - ], - "grist_doc_id": [ - os.getenv("DOC_ID_NEW_LAYOUT_THA"), - os.getenv("DOC_ID_NEW_LAYOUT_V2_THA"), - os.getenv("DOC_ID_NAME_DESC_V2_THA") - ] + "sheets": { + "new-layout": os.getenv("SHEET_NEW_LAYOUT_THA"), + "new-layout-v2": os.getenv("SHEET_NEW_LAYOUT_V2_THA"), + "name-desc-v2": os.getenv("SHEET_NAME_DESC_V2_THA") + }, + "grist_doc_id": { + "new-layout": os.getenv("DOC_ID_NEW_LAYOUT_THA"), + "new-layout-v2": os.getenv("DOC_ID_NEW_LAYOUT_V2_THA"), + "name-desc-v2": os.getenv("DOC_ID_NAME_DESC_V2_THA") + } }, - "tha_premium": { - "spreadsheet_id": os.getenv("SPREAD_SHEET_ID_THA_PREMIUM"), - "sheets": [ - os.getenv("SHEET_NEW_LAYOUT_THA_PREMIUM") - ] + "tha-no-layout": { + "spreadsheet_id": os.getenv("SPREAD_SHEET_ID_THA"), + "sheets": { + "new-layout-v2": os.getenv("SHEET_NEW_LAYOUT_V2_THA"), + "name-desc-v2": os.getenv("SHEET_NAME_DESC_V2_THA") + }, + "grist_doc_id": { + "new-layout-v2": os.getenv("DOC_ID_NEW_LAYOUT_V2_THA"), + "name-desc-v2": os.getenv("DOC_ID_NAME_DESC_V2_THA") + } + }, + "hkg": { + "spreadsheet_id": os.getenv("SPREAD_SHEET_ID_HKG"), + "sheets": { + "new-layout-v2": os.getenv("SHEET_NEW_LAYOUT_V2_HKG"), + "name-desc-v2": os.getenv("SHEET_NAME_DESC_V2_HKG") + }, + "grist_doc_id": { + "new-layout-v2": os.getenv("DOC_ID_NEW_LAYOUT_V2_HKG"), + "name-desc-v2": os.getenv("DOC_ID_NAME_DESC_V2_HKG") + } + }, + "sgp": { + "spreadsheet_id": os.getenv("SPREAD_SHEET_ID_SGP"), + "sheets": { + "new-layout-v2": os.getenv("SHEET_NEW_LAYOUT_V2_SGP"), + "name-desc-v2": os.getenv("SHEET_NAME_DESC_V2_SGP") + }, + "grist_doc_id": { + "new-layout-v2": os.getenv("DOC_ID_NEW_LAYOUT_V2_SGP"), + "name-desc-v2": os.getenv("DOC_ID_NAME_DESC_V2_SGP") + } } } @@ -86,7 +115,6 @@ def get_gspread_client(): return gspread.authorize(creds) - class LockManager: def __init__(self): # { "tha:page_catalog_group_coffee.skt": {"user_id": "A", "timestamp": 12345} } @@ -164,10 +192,12 @@ def redis_message_handler(): HEARTBEAT_CHANNEL, EXIT_CHANNEL, GET_CATALOG_CHANNEL, + GET_MENU_CATALOG_CHANNEL, UPDATE_MENU_CHANNEL, DELETE_MENU_CHANNEL, ADD_CATALOG_CHANNEL, - ADD_MENU_CHANNEL) + ADD_MENU_CHANNEL, + SWAP_MENU_CHANNEL) print(f"[*] Redis Listener started on service: {SERVICE_NAME}") @@ -257,6 +287,21 @@ def redis_message_handler(): except Exception as e: print(f"[{SERVICE_NAME}] Get catalog error: {e}") + elif channel == GET_MENU_CATALOG_CHANNEL: + if not catalog: + print(f"[{SERVICE_NAME}] Missing required parameters | Channel: {channel} | User: {user_id}") + continue + + if not lock_manager.is_authorized(country, catalog, user_id): + print(f"[{SERVICE_NAME}] get catalog menu failed: {catalog} | User: {user_id} | Message: Unauthorized access to this room") + continue + + threading.Thread( + target=process_and_stream_sheet_data, + args=(country, catalog, user_id), + daemon=True + ).start() + elif channel == ADD_MENU_CHANNEL: if not (content and catalog): print(f"[{SERVICE_NAME}] Missing required parameters | Channel: {channel} | User: {user_id}") @@ -312,6 +357,22 @@ def redis_message_handler(): except Exception as e: print(f"[{SERVICE_NAME}] Delete menu error: {e}") + elif channel == SWAP_MENU_CHANNEL: + if not (content and catalog): + print(f"[{SERVICE_NAME}] Missing required parameters | Channel: {channel}") + continue + + if not lock_manager.is_authorized(country, catalog, user_id): + print(f"[{SERVICE_NAME}] Swap menu failed: {catalog} | User: {user_id} | Message: Unauthorized access to this room") + continue + + try: + handle_swap_menu(country, catalog, content) + print(f"[{SERVICE_NAME}] Swap data success: {catalog}") + except Exception as e: + print(f"[{SERVICE_NAME}] Swap data error: {e}") + traceback.print_exc() + except Exception as e: print(f"[Redis Error] Error processing message: {e}") traceback.print_exc() @@ -341,7 +402,7 @@ def find_grist_table_id(doc_id, catalog_suffix): print(f"Error: {e}") return None -def send_stream_notification(msg_type: str, content: any, batch_id: str, current_chunk: int, total_chunks: int, user_id: str): +def send_stream_notification(msg_type: str, content: any, batch_id: str, current_chunk: int, total_chunks: int, total_items: int, user_id: str): """ msg_type: "start", "chunk", "end", "error" """ @@ -358,6 +419,7 @@ def send_stream_notification(msg_type: str, content: any, batch_id: str, current "batch_id": batch_id, "current_chunk": current_chunk, "total_chunks": total_chunks, + "total_items": total_items, "to": user_id, "content": content } @@ -372,94 +434,96 @@ def send_stream_notification(msg_type: str, content: any, batch_id: str, current def process_and_stream_sheet_data(country: str, catalog: str, user_id: str): batch_id = str(uuid.uuid4()) - send_stream_notification("start", {"message": f"Start fetching catalog: {catalog}"}, batch_id, 0, 0, user_id) + send_stream_notification("start", {"message": f"Start fetching catalog: {catalog}"}, batch_id, 0, 0, 0, user_id) try: config = COUNTRY_MAPPING.get(country) - grist_docs = config.get("grist_doc_id", []) + grist_docs = config.get("grist_doc_id", {}) - doc_nl = grist_docs[0] - doc_nv2 = grist_docs[1] - doc_nd = grist_docs[2] + has_nl = "new-layout" in grist_docs + doc_nv2 = grist_docs.get("new-layout-v2") + doc_nd = grist_docs.get("name-desc-v2") - full_table_nl = find_grist_table_id(doc_nl, catalog) full_table_nv2 = find_grist_table_id(doc_nv2, catalog) - - if not full_table_nl: - raise Exception(f"Table for catalog {catalog} not found in New-Layout") - - nl_data = fetch_grist_table_data(doc_nl, full_table_nl) - nv2_data = fetch_grist_table_data(doc_nv2, full_table_nv2) nd_all = fetch_grist_table_data(doc_nd, "Name_desc_v2") final_result = [] - for i, nl_row in enumerate(nl_data): - name_th = nl_row[2].strip() if len(nl_row) > 2 else "" - name_en = nl_row[3].strip() if len(nl_row) > 3 else "" + if not full_table_nv2: + raise Exception(f"Table for catalog {catalog} not found in New-Layout-V2") + + nv2_data = fetch_grist_table_data(doc_nv2, full_table_nv2) + + # NV2 : name row, desc row, img row, blank row (loop) + # 4 rows = 1 menu + i = 0 + while i < len(nv2_data): + nv2_obj = nv2_data[i] + nv2_row = nv2_obj["fields"] + + if len(nv2_row) <= 1 or nv2_row[1] != "name": + i += 1 + continue + + name_en = nv2_row[2].strip() if len(nv2_row) > 2 else "" + name_th = nv2_row[3].strip() if len(nv2_row) > 3 else "" if not name_th and not name_en: + i += 4 continue menu_item = { - "new_layout": {"row_index": i + 1, "values": nl_row}, "new_layout_v2": [], "name_desc_v2": [] } - # Search Targets - pairs = [ - (nl_row[6] if len(nl_row) > 6 else "-", nl_row[10] if len(nl_row) > 10 else "-"), - (nl_row[7] if len(nl_row) > 7 else "-", nl_row[11] if len(nl_row) > 11 else "-"), - (nl_row[8] if len(nl_row) > 8 else "-", nl_row[12] if len(nl_row) > 12 else "-") - ] - - search_targets = [] individual_codes = [] - for p1, p2 in pairs: - c1 = p1.strip() if p1.strip() and p1.strip() != "-" else "-" - c2 = p2.strip() if p2.strip() and p2.strip() != "-" else "-" - if c1 != "-" or c2 != "-": search_targets.append(f"{c1},{c2}") - if c1 != "-": individual_codes.append(c1) - if c2 != "-": individual_codes.append(c2) + for sub_idx in range(4): + curr_i = i + sub_idx + if curr_i < len(nv2_data): + row_data = nv2_data[curr_i]["fields"] + row_info = { + "row_index": nv2_data[curr_i]["id"], + "cells": [] + } - # find in nv2_data - for j, nv2_row in enumerate(nv2_data): - if len(nv2_row) > 10: - v2_vals = [nv2_row[8].strip(), nv2_row[9].strip(), nv2_row[10].strip()] - # Check target have or not in I, J, K - if any(t in v2_vals for t in search_targets): + if sub_idx < 3: + for c_idx in range(len(row_data)): + if c_idx < len(row_data): + row_info["cells"].append({ + "value": row_data[c_idx], + "coord": {"row": nv2_data[curr_i]["id"], "col": c_idx + 1} + }) - for sub_idx in range(3): # 3 row (Name, Desc, Img) - curr_j = j + sub_idx - if curr_j < len(nv2_data): - row_data = nv2_data[curr_j] - row_info = {"row_index": curr_j + 1, "cells": []} - if sub_idx < 2: - for c_idx in range(4, 8): - if c_idx < len(row_data): - row_info["cells"].append({ - "value": row_data[c_idx], - "coord": get_coord(curr_j, c_idx) - }) - menu_item["new_layout_v2"].append(row_info) - break + menu_item["new_layout_v2"].append(row_info) + + # all product codes (col I, J, K = index 8, 9, 10) + if sub_idx == 0 and len(row_data) > 10: + for code_idx in [8, 9, 10]: + raw_value = str(row_data[code_idx]).strip() + if not raw_value or raw_value == "-": + continue + codes = [c.strip() for c in raw_value.split(",") if c.strip() and c.strip() != "-"] + individual_codes.extend(codes) # find in name-desc-v2 for code in set(individual_codes): targets = [f"MENU.{code}.name", f"MENU.{code}.desc"] - for nd_idx, nd_row in enumerate(nd_all): + for nd_obj in nd_all: + nd_row = nd_obj["fields"] + nd_id = nd_obj["id"] if len(nd_row) > 0 and nd_row[0].strip() in targets: - nd_info = {"key": nd_row[0], "row_index": nd_idx+1, "cells": []} + nd_info = {"key": nd_row[0], "row_index": nd_id, "cells": []} for c_idx in range(4, 8): if c_idx < len(nd_row): nd_info["cells"].append({ "value": nd_row[c_idx], - "coord": get_coord(nd_idx, c_idx) + "coord": {"row": nd_id, "col": c_idx + 1} }) menu_item["name_desc_v2"].append(nd_info) final_result.append(menu_item) + i += 4 # --- Stream Chunk --- CHUNK_SIZE = 10 @@ -470,16 +534,13 @@ def process_and_stream_sheet_data(country: str, catalog: str, user_id: str): start_idx = i * CHUNK_SIZE end_idx = start_idx + CHUNK_SIZE chunk_data = final_result[start_idx:end_idx] - send_stream_notification("chunk", chunk_data, batch_id, i + 1, total_chunks, user_id) + send_stream_notification("chunk", chunk_data, batch_id, i + 1, total_chunks, total_items, user_id) - send_stream_notification("end", {"message": "All data sent successfully"}, batch_id, total_chunks, total_chunks, user_id) + send_stream_notification("end", {"message": "All data sent successfully"}, batch_id, total_chunks, total_chunks, total_items, user_id) except Exception as e: traceback.print_exc() - send_stream_notification("error", {"error_detail": str(e)}, batch_id, 0, 0, user_id) - -def get_coord(r, c): - return {"row": r + 1, "col": c + 1} + send_stream_notification("error", {"error_detail": str(e)}, batch_id, 0, 0, 0, user_id) # get catalog def get_catalogs(country: str): @@ -490,10 +551,18 @@ def get_catalogs(country: str): raise HTTPException(status_code=404, detail="Grist config not found") try: - # First sheet in Map - doc_nl_id = config["grist_doc_id"][0] + grist_docs = config["grist_doc_id"] + doc_id = None + + if grist_docs.get("new-layout"): + doc_id = grist_docs["new-layout"] + elif grist_docs.get("new-layout-v2"): + doc_id = grist_docs["new-layout-v2"] + else: + print(f"[{SERVICE_NAME}] grist_doc_id [layout] is empty in this country | {country}") + return None - url = f"{GRIST_URL.rstrip('/')}/api/docs/{doc_nl_id}/tables" + url = f"{GRIST_URL.rstrip('/')}/api/docs/{doc_id}/tables" headers = {"Authorization": f"Bearer {GRIST_API_KEY}"} resp = requests.get(url, headers=headers) @@ -533,61 +602,194 @@ def get_catalogs(country: str): ADD_MENU_CHANNEL """ -def find_catalog_block(col_a, catalog: str): - start = None - end = None - pattern = re.compile(rf"file={re.escape(catalog)}") +def get_val(arr, idx, default="-"): + if not arr or idx >= len(arr): + return default + val = arr[idx] + return val if val != "" else default - for i, val in enumerate(col_a): - val = val.strip() - - if pattern.search(val): - if start is None: - start = i - end = i - else: - if start is not None: - break - - return start, end +# List to Grist format {"fields": {"A": ..., "B": ...}} +def to_grist_record(row_list): + # chr(65+i) change to A, B, C... + return {"fields": {chr(65 + i): val for i, val in enumerate(row_list)}} def handle_add_menu(country: str, catalog: str, content: list): config = COUNTRY_MAPPING.get(country) - client = get_gspread_client() - sheet = client.open_by_key(config["spreadsheet_id"]) - - nl_name = next(s for s in config["sheets"] if "new-layout" in s.lower() and "v2" not in s.lower()) - worksheet = sheet.worksheet(nl_name) - - col_a = worksheet.col_values(1) - start, end = find_catalog_block(col_a, catalog) - - if start is None: + if not config: + print(f"[{SERVICE_NAME}] Country {country} config not found") return - insert_at = end + 2 + grist_docs = config.get("grist_doc_id", {}) + has_nl = "new-layout" in grist_docs - worksheet.insert_rows([[]] * len(content), row=insert_at) + doc_nl = grist_docs.get("new-layout") + doc_nv2 = grist_docs.get("new-layout-v2") + doc_nd = grist_docs.get("name-desc-v2") - batch_requests = [] + nl_table_id = find_grist_table_id(doc_nl, catalog) if has_nl else None + nv2_table_id = find_grist_table_id(doc_nv2, catalog) + nd_table_id = "Name_desc_v2" - for i, row in enumerate(content): - row_index = insert_at + i - cells = row.get("cells", []) + if has_nl and not nl_table_id: + print(f"[{SERVICE_NAME}] Table for {catalog} not found in doc NL") + return + if not nv2_table_id: + print(f"[{SERVICE_NAME}] Table for {catalog} not found in doc NV2") + return + def norm_val(v): return v if v not in [None, ""] else "-" + + nd_existing_data = fetch_grist_table_data(doc_nd, nd_table_id) + existing_keys = set() + if nd_existing_data: + for row_obj in nd_existing_data: + row = row_obj["fields"] + key = row[0] + if key: + existing_keys.add(key) + + # for THA + existing_nl_codes = set() + if has_nl and nl_table_id: + nl_data = fetch_grist_table_data(doc_nl, nl_table_id) + if nl_data: + for row_obj in nl_data: + row = row_obj["fields"] + codes = tuple(row[i] if i < len(row) else "-" for i in [6, 7, 8, 10, 11, 12]) + existing_nl_codes.add(codes) + + nv2_data = fetch_grist_table_data(doc_nv2, nv2_table_id) + existing_nv2_codes = set() + if nv2_data: + for row_obj in nv2_data: + row = row_obj["fields"] + is_name = row[1] if len(row) > 1 else "" + if is_name == "name": + codes = (norm_val(row[8] if len(row) > 8 else "-"), + norm_val(row[9] if len(row) > 9 else "-"), + norm_val(row[10] if len(row) > 10 else "-")) + existing_nv2_codes.add(codes) + + nl_records = [] + nv2_records = [] + nd_records = [] + + for item in content: + cells = item.get("cells", []) if not cells: continue + + payload_lang = item.get("payload", {}) + lang_name = payload_lang.get("lang_name", ["", "", "", ""]) + lang_desc = payload_lang.get("lang_desc", ["", "", "", ""]) - end_col = col_to_letter(len(cells)) + # ========================================== + # New-Layout (THA) + # ========================================== + if has_nl: + current_nl_tuple = tuple(norm_val(get_val(cells, i)) for i in [6,7,8,10,11,12]) + if current_nl_tuple not in existing_nl_codes: + nl_records.append(to_grist_record(cells)) + existing_nl_codes.add(current_nl_tuple) - batch_requests.append({ - "range": f"A{row_index}:{end_col}{row_index}", - "values": [cells] - }) + # ========================================== + # New-Layout-V2 (All country) + # ========================================== + hot_code = f"{get_val(cells, 6)},{get_val(cells, 10)}" + cold_code = f"{get_val(cells, 7)},{get_val(cells, 11)}" + blend_code = f"{get_val(cells, 8)},{get_val(cells, 12)}" + current_nv2_tuple = (hot_code, cold_code, blend_code) - if batch_requests: - worksheet.batch_update(batch_requests, value_input_option="USER_ENTERED") + if current_nv2_tuple not in existing_nv2_codes: + name_row = ["", "name", get_val(cells, 3), get_val(cells, 2), get_val(lang_name, 0), get_val(lang_name, 1), get_val(lang_name, 2), get_val(lang_name, 3), + hot_code, cold_code, blend_code, "", "", "", "", "", "", "", + get_val(cells, 14), get_val(cells, 15), get_val(cells, 16), get_val(cells, 17), get_val(cells, 18)] + + desc_row = ["", "desc", get_val(cells, 5), get_val(cells, 4), get_val(lang_desc, 0), get_val(lang_desc, 1), get_val(lang_desc, 2), get_val(lang_desc, 3), + "||||||||||||||||||||||||||", "||||||||||||||||||||||||||", "||||||||||||||||||||||||||", "", "", "", "", "", "", "", + "-", "-", "-", "-", "-"] + + img_row = ["", "img", get_val(cells, 9), "-", "-", "-", "-", "-", get_val(cells, 13), + "||||||||||||||||||||||||||", "||||||||||||||||||||||||||", "", "", "", "", "", "", "", + "-", "-", "-", "-", "-"] + + blank_row = [""] * 23 + + nv2_records.extend([to_grist_record(name_row), to_grist_record(desc_row), to_grist_record(img_row), to_grist_record(blank_row)]) + existing_nv2_codes.add(current_nv2_tuple) + + # ========================================== + # Name-desc-V2 (All country) + # ========================================== + product_code_indices = [6, 7, 8, 10, 11, 12] + + for p_idx in product_code_indices: + code = get_val(cells, p_idx) + if code == "-" or not code: + continue + + menu_name_key = f"MENU.{code}.name" + + if menu_name_key in existing_keys: + continue + + parts = code.split('-') + drink_type = "UNKNOWN" + drink_type_th = "" + + if len(parts) >= 3: + type_id = parts[2] + if type_id == "01": + drink_type = "HOT" + drink_type_th = "ร้อน" + elif type_id == "02": + drink_type = "ICED" + drink_type_th = "เย็น" + elif type_id == "03": + drink_type = "SMOOTHIE" + drink_type_th = "ปั่น" + + name_en = get_val(cells, 3, "") + name_th = get_val(cells, 2, "") + + if drink_type == "SMOOTHIE": + prefix = f"{name_en} {drink_type}".strip() + else: + prefix = f"{drink_type} {name_en}".strip() + + prefix_th = f"{name_th} {drink_type_th}".strip() if drink_type_th else name_th + + nd_name_row = [menu_name_key, get_val(cells, 9), prefix, prefix_th, get_val(lang_name, 0), get_val(lang_name, 1), get_val(lang_name, 2), get_val(lang_name, 3)] + nd_desc_row = [f"MENU.{code}.desc", "-", get_val(cells, 5), get_val(cells, 4), get_val(lang_desc, 0), get_val(lang_desc, 1), get_val(lang_desc, 2), get_val(lang_desc, 3)] + + nd_records.extend([to_grist_record(nd_name_row), to_grist_record(nd_desc_row)]) + existing_keys.add(menu_name_key) + + headers = {"Authorization": f"Bearer {GRIST_API_KEY}", "Content-Type": "application/json"} + + def add_records_to_grist(doc_id, table_id, records): + if not records or not doc_id or not table_id: + return + url = f"{GRIST_URL.rstrip('/')}/api/docs/{doc_id}/tables/{table_id}/records" + try: + resp = requests.post(url, headers=headers, json={"records": records}) + if resp.status_code != 200: + print(f"Error adding to {table_id}: {resp.text}") + except Exception as e: + print(f"Request failed for {table_id}: {e}") + + # NL (เฉพาะ THA) + if has_nl and nl_records: + add_records_to_grist(doc_nl, nl_table_id, nl_records) + + # NV2 + if nv2_table_id and nv2_records: + add_records_to_grist(doc_nv2, nv2_table_id, nv2_records) + + # ND_V2 + if nd_records: + add_records_to_grist(doc_nd, nd_table_id, nd_records) """ @@ -597,10 +799,13 @@ ADD_CATALOG_CHANNEL def handle_add_catalog(country: str, catalog_name: str, catalog: str): config = COUNTRY_MAPPING.get(country) - grist_docs = config.get("grist_doc_id", []) + grist_docs = config.get("grist_doc_id", {}) - doc_nl = grist_docs[0] - doc_nv2 = grist_docs[1] + docs_to_create = [] + if "new-layout" in grist_docs: + docs_to_create.append(grist_docs["new-layout"]) + if "new-layout-v2" in grist_docs: + docs_to_create.append(grist_docs["new-layout-v2"]) table_label = f"Name={catalog_name},file={catalog}" table_id = re.sub(r'[^a-zA-Z0-9_]', '_', table_label) @@ -624,35 +829,44 @@ def handle_add_catalog(country: str, catalog_name: str, catalog: str): else: print(f"[{SERVICE_NAME}] Failed to create table: {resp.text}") - create_table_in_doc(doc_nl) - create_table_in_doc(doc_nv2) + for doc_id in docs_to_create: + create_table_in_doc(doc_id) """ UPDATE_MENU_CHANNEL """ -# update sheet + def update_sheets(country: str, catalog: str, content: list): config = COUNTRY_MAPPING.get(country) - grist_docs = config.get("grist_doc_id", []) - - doc_map = { - "new_layout": grist_docs[0], - "new_layout_v2": grist_docs[1], - "name_desc_v2": grist_docs[2] - } + grist_docs = config.get("grist_doc_id", {}) + has_nl = "new-layout" in grist_docs - full_table_name = find_grist_table_id(grist_docs[0], catalog) + doc_map = {} + if "new-layout" in grist_docs: + doc_map["new_layout"] = grist_docs["new-layout"] + if "new-layout-v2" in grist_docs: + doc_map["new_layout_v2"] = grist_docs["new-layout-v2"] + if "name-desc-v2" in grist_docs: + doc_map["name_desc_v2"] = grist_docs["name-desc-v2"] + + full_table_name = None + for key in ["new_layout", "new_layout_v2"]: + if key in doc_map: + full_table_name = find_grist_table_id(doc_map[key], catalog) + if full_table_name: + break if not full_table_name: - raise Exception(f"Table for catalog {catalog} not found in New-Layout") + raise Exception(f"Table for catalog {catalog} not found") headers = {"Authorization": f"Bearer {GRIST_API_KEY}", "Content-Type": "application/json"} - # {"id": row_index, "fields": {"A": val, "B": val}} + nv2_updates_for_nl = [] + for item in content: - for sheet_key in ["new_layout", "new_layout_v2", "name_desc_v2"]: + for sheet_key in doc_map.keys(): rows = item.get(sheet_key, []) if sheet_key == "new_layout": rows = [rows] if rows else [] @@ -661,20 +875,20 @@ def update_sheets(country: str, catalog: str, content: list): continue doc_id = doc_map[sheet_key] - target_table = "Name_desc_v2" if sheet_key == "name_desc_v2" else full_table_name records_to_update = [] for row in rows: cells = row.get("cells", []) - if not cells: continue + if not cells: + continue row_i = cells[0]["coord"]["row"] fields = {} for cell in cells: - col_letter = col_to_letter(cell["coord"]["col"]) # column A, B, C... + col_letter = col_to_letter(cell["coord"]["col"]) fields[col_letter] = str(cell["value"]) records_to_update.append({ @@ -682,12 +896,205 @@ def update_sheets(country: str, catalog: str, content: list): "fields": fields }) + # keep nv2 data to sync in new-layout [If is tha] + if has_nl and sheet_key == "new_layout_v2": + nv2_updates_for_nl.append({ + "row_id": row_i, + "fields": fields, + "cells": cells + }) + if records_to_update: - update_url = f"{GRIST_URL.rstrip('/')}/api/docs/{doc_id}/tables/{target_table}/records" - resp = requests.patch(update_url, headers=headers, json={"records": records_to_update}) + grouped_updates = {} - if resp.status_code != 200: - print(f"[{SERVICE_NAME}] Grist update failed for {target_table}: {resp.text}") + for record in records_to_update: + field_keys = tuple(sorted(record["fields"].keys())) + if field_keys not in grouped_updates: + grouped_updates[field_keys] = [] + grouped_updates[field_keys].append(record) + + update_url = f"{GRIST_URL.rstrip('/')}/api/docs/{doc_id}/tables/{target_table}/records" + + for field_keys, batch in grouped_updates.items(): + resp = requests.patch(update_url, headers=headers, json={"records": batch}) + if resp.status_code != 200: + print(f"[{SERVICE_NAME}] Grist update failed for {target_table} | Doc_id : {doc_id} | Status : {resp.text}") + + # === SYNC: new-layout-v2 → new-layout (THA) === + if has_nl and nv2_updates_for_nl: + sync_nv2_to_nl(country, catalog, nv2_updates_for_nl, headers) + + +def sync_nv2_to_nl(country: str, catalog: str, nv2_updates: list, headers: dict): + """Sync updates จาก new-layout-v2 ไปอัปเดต new-layout สำหรับ THA""" + config = COUNTRY_MAPPING.get(country) + grist_docs = config.get("grist_doc_id", {}) + + doc_nl = grist_docs["new-layout"] + doc_nv2 = grist_docs["new-layout-v2"] + + nl_table_id = find_grist_table_id(doc_nl, catalog) + nv2_table_id = find_grist_table_id(doc_nv2, catalog) + + if not nl_table_id or not nv2_table_id: + print(f"[{SERVICE_NAME}] Tables not found for sync") + return + + nv2_all_data = fetch_grist_table_data(doc_nv2, nv2_table_id) + nl_all_data = fetch_grist_table_data(doc_nl, nl_table_id) + + # === NL codes map === + # NL tuple: (G, H, I, K, L, M) = (index 6, 7, 8, 10, 11, 12) + nl_codes_map = {} + for row_obj in nl_all_data: + row = row_obj["fields"] + g = str(row[6]).strip() if len(row) > 6 else "-" + h = str(row[7]).strip() if len(row) > 7 else "-" + i = str(row[8]).strip() if len(row) > 8 else "-" + k = str(row[10]).strip() if len(row) > 10 else "-" + l = str(row[11]).strip() if len(row) > 11 else "-" + m = str(row[12]).strip() if len(row) > 12 else "-" + nl_codes_map[(g, h, i, k, l, m)] = row_obj["id"] + + # === NV2 → NL mapping (any row in block to NL in one row) === + nv2_to_nl_map = {} + + for i in range(len(nv2_all_data)): + row = nv2_all_data[i]["fields"] + + if len(row) > 1 and str(row[1]).strip() == "name": + # productCodes NV2 name row: I(8), J(9), K(10) + raw_i = str(row[8]).strip() if len(row) > 8 else "-" + raw_j = str(row[9]).strip() if len(row) > 9 else "-" + raw_k = str(row[10]).strip() if len(row) > 10 else "-" + + # Split comma-separated + parts_i = [p.strip() for p in raw_i.split(",") if p.strip() and p.strip() != "-"] + parts_j = [p.strip() for p in raw_j.split(",") if p.strip() and p.strip() != "-"] + parts_k = [p.strip() for p in raw_k.split(",") if p.strip() and p.strip() != "-"] + + col_g = parts_i[0] if len(parts_i) > 0 else "-" + col_k = parts_i[1] if len(parts_i) > 1 else "-" + col_h = parts_j[0] if len(parts_j) > 0 else "-" + col_l = parts_j[1] if len(parts_j) > 1 else "-" + col_i = parts_k[0] if len(parts_k) > 0 else "-" + col_m = parts_k[1] if len(parts_k) > 1 else "-" + + # find NL row to matching codes + nl_id = nl_codes_map.get((col_g, col_h, col_i, col_k, col_l, col_m)) + + if nl_id: + for offset in range(4): + if (i + offset) < len(nv2_all_data): + nv2_row_obj = nv2_all_data[i + offset] + nv2_id = nv2_row_obj["id"] + nv2_row = nv2_row_obj["fields"] + row_type = str(nv2_row[1]).strip() if len(nv2_row) > 1 else "" + nv2_to_nl_map[nv2_id] = { + "nl_id": nl_id, + "row_type": row_type + } + + nl_records_to_update = [] + + for update in nv2_updates: + nv2_row_id = update["row_id"] + update_fields = update["fields"] # {col_letter: value} + + mapping = nv2_to_nl_map.get(nv2_row_id) + if not mapping: + print(f"[{SERVICE_NAME}] NL row not found for NV2 row {nv2_row_id}") + continue + + nl_target_id = mapping["nl_id"] + row_type = mapping["row_type"] + nl_fields = {} + + if row_type == "name": + # C→D (name_en), D→C (name_th) + if 'C' in update_fields: + nl_fields['D'] = update_fields['C'] + if 'D' in update_fields: + nl_fields['C'] = update_fields['D'] + + # I→G,K + if 'I' in update_fields: + val = update_fields['I'] + parts = [p.strip() for p in str(val).split(",") if p.strip()] + if len(parts) > 0: + nl_fields['G'] = parts[0] + if len(parts) > 1: + nl_fields['K'] = parts[1] + + # J→H,L + if 'J' in update_fields: + val = update_fields['J'] + parts = [p.strip() for p in str(val).split(",") if p.strip()] + if len(parts) > 0: + nl_fields['H'] = parts[0] + if len(parts) > 1: + nl_fields['L'] = parts[1] + + # K→I,M + if 'K' in update_fields: + val = update_fields['K'] + parts = [p.strip() for p in str(val).split(",") if p.strip()] + if len(parts) > 0: + nl_fields['I'] = parts[0] + if len(parts) > 1: + nl_fields['M'] = parts[1] + + # S→O, T→P, U→R, V→R, W→S + if 'S' in update_fields: + nl_fields['O'] = update_fields['S'] + if 'T' in update_fields: + nl_fields['P'] = update_fields['T'] + if 'U' in update_fields: + nl_fields['Q'] = update_fields['U'] + if 'V' in update_fields: + nl_fields['R'] = update_fields['V'] + if 'W' in update_fields: + nl_fields['S'] = update_fields['W'] + + elif row_type == "desc": + # C→F (desc_en), D→E (desc_th) + if 'C' in update_fields: + nl_fields['F'] = update_fields['C'] + if 'D' in update_fields: + nl_fields['E'] = update_fields['D'] + + elif row_type == "img": + # C→J (img) + if 'C' in update_fields: + nl_fields['J'] = update_fields['C'] + # I→N + if 'I' in update_fields: + nl_fields['N'] = update_fields['I'] + + if nl_fields: + nl_records_to_update.append({ + "id": nl_target_id, + "fields": nl_fields + }) + + # === Update to NL === + if nl_records_to_update: + update_url = f"{GRIST_URL.rstrip('/')}/api/docs/{doc_nl}/tables/{nl_table_id}/records" + + # Group by field keys (Grist requirement) + grouped_updates = {} + for record in nl_records_to_update: + field_keys = tuple(sorted(record["fields"].keys())) + if field_keys not in grouped_updates: + grouped_updates[field_keys] = [] + grouped_updates[field_keys].append(record) + + for field_keys, batch in grouped_updates.items(): + resp = requests.patch(update_url, headers=headers, json={"records": batch}) + if resp.status_code != 200: + print(f"[{SERVICE_NAME}] NL sync update failed: {resp.text}") + else: + print(f"[{SERVICE_NAME}] Synced {len(batch)} NL records") # column to A1 spreadsheet format @@ -703,88 +1110,281 @@ def col_to_letter(col: int) -> str: DELETE_MENU_CHANNEL """ + def handle_delete_menu(country: str, catalog: str, content: list): config = COUNTRY_MAPPING.get(country) - client = get_gspread_client() - sheet = client.open_by_key(config["spreadsheet_id"]) - - nl_name = next(s for s in config["sheets"] if "new-layout" in s.lower() and "v2" not in s.lower()) - worksheet = sheet.worksheet(nl_name) - - col_a = worksheet.col_values(1) - - # find catalog start range - pattern = re.compile(rf"file={re.escape(catalog)}") - - start = None - end = None - - for i, val in enumerate(col_a): - val = val.strip() - - if start is None: - if pattern.search(val): - start = i - end = i - else: - if "file=" in val: - break - - end = i - - if start is None: + if not config: + print(f"[{SERVICE_NAME}] Country {country} config not found") return - rows = [item["row_index"] for item in content if "row_index" in item] + grist_docs = config.get("grist_doc_id", {}) + has_nl = "new-layout" in grist_docs - # specifically in block - print("start, end:", start, end) - print("rows before filter:", rows) - rows = [ - r for r in rows - if (start + 1) <= r <= (end + 1) + doc_nl = grist_docs.get("new-layout") + doc_nv2 = grist_docs.get("new-layout-v2") + + nl_table_id = find_grist_table_id(doc_nl, catalog) if has_nl else None + nv2_table_id = find_grist_table_id(doc_nv2, catalog) + + if not nv2_table_id: + print(f"[{SERVICE_NAME}] NV2 Table not found for catalog: {catalog}") + return + + target_ids = {int(item.get("target_id")) for item in content if item.get("target_id")} + + if not target_ids: + print(f"[{SERVICE_NAME}] Payload to delete is empty.") + return + + headers = {"Authorization": f"Bearer {GRIST_API_KEY}", "Content-Type": "application/json"} + + try: + nv2_all_data = fetch_grist_table_data(doc_nv2, nv2_table_id) + + # ProductCodes NV2 name rows that match with target_ids + # (col_G, col_H, col_I, col_K, col_L, col_M) + search_nl_codes_sets = [] + nv2_ids_to_delete = [] + + for i in range(len(nv2_all_data)): + row_obj = nv2_all_data[i] + row = row_obj["fields"] + + if len(row) > 1 and row[1] == "name" and row_obj["id"] in target_ids: + # === ProductCodes NV2 col I, J, K (index 8, 9, 10) === + # comma-separated + + # col I (index 8) → col G (index 6) and col K (index 10) of NL + raw_i = str(row[8]).strip() if len(row) > 8 else "-" + parts_i = [p.strip() for p in raw_i.split(",") if p.strip() and p.strip() != "-"] + col_g = parts_i[0] if len(parts_i) > 0 else "-" + col_k = parts_i[1] if len(parts_i) > 1 else "-" + + # col J (index 9) → col H (index 7) and col L (index 11) of NL + raw_j = str(row[9]).strip() if len(row) > 9 else "-" + parts_j = [p.strip() for p in raw_j.split(",") if p.strip() and p.strip() != "-"] + col_h = parts_j[0] if len(parts_j) > 0 else "-" + col_l = parts_j[1] if len(parts_j) > 1 else "-" + + # col K (index 10) → col I (index 8) and col M (index 12) of NL + raw_k = str(row[10]).strip() if len(row) > 10 else "-" + parts_k = [p.strip() for p in raw_k.split(",") if p.strip() and p.strip() != "-"] + col_i = parts_k[0] if len(parts_k) > 0 else "-" + col_m = parts_k[1] if len(parts_k) > 1 else "-" + + # To find in new-layout + search_nl_codes_sets.append((col_g, col_h, col_i, col_k, col_l, col_m)) + + # NV2 block IDs (4 row: name, desc, img, blank) [Delete] + for offset in range(4): + if (i + offset) < len(nv2_all_data): + record_to_del = nv2_all_data[i + offset]["id"] + if record_to_del not in nv2_ids_to_delete: + nv2_ids_to_delete.append(record_to_del) + + # === Delete in new-layout (THA) === + if has_nl and search_nl_codes_sets: + nl_all_data = fetch_grist_table_data(doc_nl, nl_table_id) + nl_ids_to_delete = [] + + for row_obj in nl_all_data: + row = row_obj["fields"] + + # NL: col G(6), H(7), I(8), K(10), L(11), M(12) + nl_g = str(row[6]).strip() if len(row) > 6 else "-" + nl_h = str(row[7]).strip() if len(row) > 7 else "-" + nl_i = str(row[8]).strip() if len(row) > 8 else "-" + nl_k = str(row[10]).strip() if len(row) > 10 else "-" + nl_l = str(row[11]).strip() if len(row) > 11 else "-" + nl_m = str(row[12]).strip() if len(row) > 12 else "-" + + nl_tuple = (nl_g, nl_h, nl_i, nl_k, nl_l, nl_m) + + if nl_tuple in search_nl_codes_sets: + nl_ids_to_delete.append(row_obj["id"]) + + if nl_ids_to_delete: + del_nl_url = f"{GRIST_URL.rstrip('/')}/api/docs/{doc_nl}/tables/{nl_table_id}/records/delete" + resp_nl = requests.post(del_nl_url, headers=headers, json=nl_ids_to_delete) + print(f"[{SERVICE_NAME}] Deleted NL IDs: {nl_ids_to_delete} | Status: {resp_nl.status_code}") + + # === Delete in new-layout-v2 (All country) === + if nv2_ids_to_delete: + del_nv2_url = f"{GRIST_URL.rstrip('/')}/api/docs/{doc_nv2}/tables/{nv2_table_id}/records/delete" + resp_v2 = requests.post(del_nv2_url, headers=headers, json=nv2_ids_to_delete) + print(f"[{SERVICE_NAME}] Deleted NV2 IDs: {nv2_ids_to_delete} | Status: {resp_v2.status_code}") + + except Exception as e: + print(f"[{SERVICE_NAME}] Delete menu error: {e}") + traceback.print_exc() + +""" + +SWAP_MENU_CHANNEL + +""" + +def format_for_grist(data): + # List to Dict + if isinstance(data, list): + return {col_to_letter(i + 1): val for i, val in enumerate(data)} + return data + +def handle_swap_menu(country: str, catalog: str, content: list): + config = COUNTRY_MAPPING.get(country) + grist_docs = config.get("grist_doc_id", {}) + has_nl = "new-layout" in grist_docs + + doc_nl = grist_docs.get("new-layout") + doc_nv2 = grist_docs.get("new-layout-v2") + + nl_table = find_grist_table_id(doc_nl, catalog) if has_nl else None + nv2_table = find_grist_table_id(doc_nv2, catalog) + + if not nv2_table: + print(f"[{SERVICE_NAME}] Swap failed: NV2 Table not found") + return + + nl_all = fetch_grist_table_data(doc_nl, nl_table) if has_nl and nl_table else [] + nv2_all = fetch_grist_table_data(doc_nv2, nv2_table) + + nl_map = {row["id"]: row["fields"] for row in nl_all} if has_nl else {} + nv2_map = {row["id"]: row["fields"] for row in nv2_all} + + nv2_to_nl_map = {} + if has_nl: + nl_codes_map = {} + for row_obj in nl_all: + row = row_obj["fields"] + g = str(row[6]).strip() if len(row) > 6 else "-" + h = str(row[7]).strip() if len(row) > 7 else "-" + i = str(row[8]).strip() if len(row) > 8 else "-" + k = str(row[10]).strip() if len(row) > 10 else "-" + l = str(row[11]).strip() if len(row) > 11 else "-" + m = str(row[12]).strip() if len(row) > 12 else "-" + nl_codes_map[(g, h, i, k, l, m)] = row_obj["id"] + + for i in range(len(nv2_all)): + row = nv2_all[i]["fields"] + if len(row) > 1 and str(row[1]).strip() == "name": + raw_i = str(row[8]).strip() if len(row) > 8 else "-" + raw_j = str(row[9]).strip() if len(row) > 9 else "-" + raw_k = str(row[10]).strip() if len(row) > 10 else "-" + + parts_i = [p.strip() for p in raw_i.split(",") if p.strip() and p.strip() != "-"] + parts_j = [p.strip() for p in raw_j.split(",") if p.strip() and p.strip() != "-"] + parts_k = [p.strip() for p in raw_k.split(",") if p.strip() and p.strip() != "-"] + + col_g = parts_i[0] if len(parts_i) > 0 else "-" + col_k = parts_i[1] if len(parts_i) > 1 else "-" + col_h = parts_j[0] if len(parts_j) > 0 else "-" + col_l = parts_j[1] if len(parts_j) > 1 else "-" + col_i = parts_k[0] if len(parts_k) > 0 else "-" + col_m = parts_k[1] if len(parts_k) > 1 else "-" + + nl_id = nl_codes_map.get((col_g, col_h, col_i, col_k, col_l, col_m)) + + if nl_id: + for offset in range(4): + if (i + offset) < len(nv2_all): + nv2_to_nl_map[nv2_all[i + offset]["id"]] = nl_id + + nv2_original = {} # {target_id: [block_fields]} + nl_original = {} # {nl_target_id: nl_fields} + + for pair in content: + id_src = pair.get("source_id") + id_tgt = pair.get("target_id") + if not id_src or not id_tgt: continue + + # NV2 block original source + block_src = find_nv2_block_by_id(nv2_all, id_src) + if block_src: + # original fields + nv2_original[id_tgt] = [list(nv2_map[sid]) for sid in block_src] + + # NL original source (THA) + if has_nl: + nl_src_id = nv2_to_nl_map.get(id_src) + if nl_src_id: + nl_original[nv2_to_nl_map.get(id_tgt)] = list(nl_map[nl_src_id]) + + + nv2_records_to_update = [] + nl_records_to_update = [] + + for pair in content: + id_src = pair.get("source_id") + id_tgt = pair.get("target_id") + if not id_src or not id_tgt: continue + + block_tgt = find_nv2_block_by_id(nv2_all, id_tgt) + + # === Swap NV2 === + original_block = nv2_original.get(id_tgt, []) + for i in range(min(len(block_tgt), len(original_block))): + id_v2_tgt = block_tgt[i] + fields_src = original_block[i] + + nv2_records_to_update.append({ + "id": id_v2_tgt, + "fields": format_for_grist(fields_src) + }) + + # === Swap NL (THA) === + if has_nl: + nl_tgt_id = nv2_to_nl_map.get(id_tgt) + nl_src_original = nl_original.get(nl_tgt_id) + + if nl_tgt_id and nl_src_original: + nl_records_to_update.append({ + "id": nl_tgt_id, + "fields": format_for_grist(nl_src_original) + }) + + headers = {"Authorization": f"Bearer {GRIST_API_KEY}", "Content-Type": "application/json"} + + if has_nl and nl_records_to_update: + resp_v1 = requests.patch(f"{GRIST_URL}/api/docs/{doc_nl}/tables/{nl_table}/records", headers=headers, json={"records": nl_records_to_update}) + print(f"[{SERVICE_NAME}] Swap New-layout Result: {resp_v1.status_code}") + + if nv2_records_to_update: + resp_v2 = requests.patch(f"{GRIST_URL}/api/docs/{doc_nv2}/tables/{nv2_table}/records", headers=headers, json={"records": nv2_records_to_update}) + print(f"[{SERVICE_NAME}] Swap New-layout-v2 Result: {resp_v2.status_code}") + +def find_nv2_block_by_id(nv2_data, name_row_id): + """Find block 4 rows by name row id """ + for i, nv2_obj in enumerate(nv2_data): + if nv2_obj["id"] == name_row_id: + return [nv2_data[i+j]["id"] for j in range(4) if (i+j) < len(nv2_data)] + return [] + +def generate_search_targets(nl_row): + """ New-layout-v2 [format] """ + pairs = [ + (nl_row[6] if len(nl_row) > 6 else "-", nl_row[10] if len(nl_row) > 10 else "-"), + (nl_row[7] if len(nl_row) > 7 else "-", nl_row[11] if len(nl_row) > 11 else "-"), + (nl_row[8] if len(nl_row) > 8 else "-", nl_row[12] if len(nl_row) > 12 else "-") ] + targets = [] + for p1, p2 in pairs: + c1 = str(p1).strip() if str(p1).strip() and str(p1).strip() != "-" else "-" + c2 = str(p2).strip() if str(p2).strip() and str(p2).strip() != "-" else "-" + if c1 != "-" or c2 != "-": + targets.append(f"{c1},{c2}") + return targets - if not rows: - return - - ranges = group_consecutive_rows(rows) - - requests = [] - - for start_r, end_r in reversed(ranges): - requests.append({ - "deleteDimension": { - "range": { - "sheetId": worksheet.id, - "dimension": "ROWS", - "startIndex": start_r - 1, - "endIndex": end_r - } - } - }) - - sheet.batch_update({ - "requests": requests - }) - -def group_consecutive_rows(rows): - ranges = [] - rows = sorted(rows) - - start = rows[0] - prev = rows[0] - - for r in rows[1:]: - if r == prev + 1: - prev = r - else: - ranges.append((start, prev)) - start = r - prev = r - - ranges.append((start, prev)) - return ranges +def find_nv2_block_by_targets(nv2_data, search_targets): + """ find block target in new-layout-v2 """ + search_set = set(search_targets) + for i, nv2_obj in enumerate(nv2_data): + nv2_row = nv2_obj["fields"] + if len(nv2_row) > 10: + # I, J, K (index 8, 9, 10) + v2_vals = [str(nv2_row[8]).strip(), str(nv2_row[9]).strip(), str(nv2_row[10]).strip()] + if any(v in search_set for v in v2_vals): + return [nv2_data[i+j]["id"] for j in range(4) if (i+j) < len(nv2_data)] + return [] app = FastAPI() @@ -819,35 +1419,46 @@ def get_api_catalogs(req: CatalogRequest): country = payload.values.country.strip().lower() config = COUNTRY_MAPPING.get(country) - if not config or not config["sheets"]: - raise HTTPException(status_code=404, detail="Country or sheets not found") + if not config or not config.get("grist_doc_id"): + raise HTTPException(status_code=404, detail="Country or Grist config not found") try: - # First sheet in Map - target_sheet_name = config["sheets"][0] - client = get_gspread_client() - spreadsheet = client.open_by_key(config["spreadsheet_id"]) - worksheet = spreadsheet.worksheet(target_sheet_name) + grist_docs = config["grist_doc_id"] + doc_id = None + + if grist_docs.get("new-layout"): + doc_id = grist_docs["new-layout"] + elif grist_docs.get("new-layout-v2"): + doc_id = grist_docs["new-layout-v2"] + else: + print(f"[{SERVICE_NAME}] grist_doc_id [layout] is empty in this country | {country}") + return None - # Get A column - col_a = worksheet.col_values(1) + url = f"{GRIST_URL.rstrip('/')}/api/docs/{doc_id}/tables" + headers = {"Authorization": f"Bearer {GRIST_API_KEY}"} + resp = requests.get(url, headers=headers) catalogs = [] - # Skip Row 1 (Index 0 in List) - for row_idx in range(1, len(col_a)): - val = col_a[row_idx].strip() - if not val or val in ["-", "IGNORE"]: - continue - - # Specify file=... - # format: Name=Test,file=page_catalog_group_recommend.skt - match = re.search(r'file=([^,]+)', val) - if match: - catalog_name = match.group(1).strip() - lock_info = lock_manager.get_lock_info(country, catalog_name) + if resp.status_code == 200: + tables = resp.json().get("tables", []) + for i, t in enumerate(tables): + t_id = t["id"] + + if t_id.startswith("Grist") or t_id.lower() == "name_desc_v2": + continue + + reconstructed_name = reconstruct_table_name(t_id) + + match = re.search(r'file=([^,]+)', reconstructed_name) + if match: + clean_catalog = match.group(1).strip() + else: + clean_catalog = t_id + + lock_info = lock_manager.get_lock_info(country, clean_catalog) catalogs.append({ - "catalog": catalog_name, - "row_index": row_idx + 1, # Index in Google Sheet + "catalog": clean_catalog, + "row_index": i, "status": "locked" if lock_info["is_locked"] else "free", "locked_by": lock_info["locked_by"] }) @@ -950,17 +1561,19 @@ def sync_sheets_to_grist(country_key): config = COUNTRY_MAPPING[country_key] spreadsheet_id = config["spreadsheet_id"] - sheet_names = config["sheets"] - grist_doc_ids = config.get("grist_doc_id", []) + sheets = config["sheets"] # dict แทน list + grist_doc_ids = config.get("grist_doc_id", {}) gc = get_gspread_client() spreadsheet = gc.open_by_key(spreadsheet_id) - for index, sheet_name in enumerate(sheet_names): - if not sheet_name or index >= len(grist_doc_ids): + for sheet_key, sheet_name in sheets.items(): + if not sheet_name: continue - doc_id = grist_doc_ids[index] + doc_id = grist_doc_ids.get(sheet_key) + if not doc_id: + continue try: worksheet = spreadsheet.worksheet(sheet_name) @@ -969,12 +1582,11 @@ def sync_sheets_to_grist(country_key): continue header = all_values[0] - data_rows = all_values[1:] - if "new-layout" in sheet_name.lower(): + if sheet_key in ["new-layout-v2", "new-layout"]: process_new_layout_sheet(doc_id, header, data_rows) - elif "name-desc-v2" in sheet_name.lower(): + elif sheet_key == "name-desc-v2": upload_to_grist_self_hosted(doc_id, "name-desc-v2", header, data_rows) except Exception as e: @@ -1118,6 +1730,7 @@ def fetch_grist_table_data(doc_id, table_id): parsed_rows = [] for r in records: fields = r.get("fields", {}) + record_id = r.get("id") max_idx = -1 for k in fields.keys(): @@ -1130,7 +1743,10 @@ def fetch_grist_table_data(doc_id, table_id): if re.match(r'^[A-Z]+$', k): row[col_to_index(k)] = str(v) if v is not None else "" - parsed_rows.append(row) + parsed_rows.append({ + "id": record_id, + "fields": row + }) return parsed_rows @@ -1141,35 +1757,33 @@ def sync_grist_to_sheets(country_key): config = COUNTRY_MAPPING[country_key] spreadsheet_id = config["spreadsheet_id"] - sheet_names = config["sheets"] - grist_doc_ids = config.get("grist_doc_id", []) + sheets = config["sheets"] + grist_doc_ids = config.get("grist_doc_id", {}) gc = get_gspread_client() spreadsheet = gc.open_by_key(spreadsheet_id) - for index, sheet_name in enumerate(sheet_names): - if not sheet_name or index >= len(grist_doc_ids): + for sheet_key, sheet_name in sheets.items(): + if not sheet_name: continue - doc_id = grist_doc_ids[index] + doc_id = grist_doc_ids.get(sheet_key) if not doc_id: continue try: worksheet = spreadsheet.worksheet(sheet_name) - # Case Name-desc-v2 - if "name-desc-v2" in sheet_name.lower(): - # print(f"Fetching Name_desc_v2 from doc: {doc_id}...") + if sheet_key == "name-desc-v2": rows = fetch_grist_table_data(doc_id, "Name_desc_v2") if rows: + sheet_rows = [r["fields"] for r in rows] worksheet.batch_clear(["A2:ZZ"]) - worksheet.update(values=rows, range_name="A2") + worksheet.update(values=sheet_rows, range_name="A2") print(f"[{SERVICE_NAME}] Updated {len(rows)} rows to sheet '{sheet_name}'.") - # Case New-layout / New-layout-v2 - elif "new-layout" in sheet_name.lower(): + elif sheet_key in ["new-layout", "new-layout-v2"]: print(f"Fetching multiple tables from doc: {doc_id}...") all_tables = get_all_grist_tables(doc_id) all_new_rows = [] @@ -1182,15 +1796,15 @@ def sync_grist_to_sheets(country_key): if rows: original_name = reconstruct_table_name(t_id) all_new_rows.append([original_name]) - - all_new_rows.extend(rows) + sheet_rows = [r["fields"] for r in rows] + all_new_rows.extend(sheet_rows) if all_new_rows: worksheet.batch_clear(["A2:ZZ"]) worksheet.update(values=all_new_rows, range_name="A2") - print(f"[{SERVICE_NAME}] Updated {len(all_new_rows)} lines (incl. Table Names) to sheet '{sheet_name}'.") + print(f"[{SERVICE_NAME}] Updated {len(all_new_rows)} lines to sheet '{sheet_name}'.") else: - print(f"[{SERVICE_NAME}] No data found in any tables for sheet '{sheet_name}'.") + print(f"[{SERVICE_NAME}] No data found for sheet '{sheet_name}'.") except Exception as e: print(f"[{SERVICE_NAME}] Error syncing to sheet {sheet_name}: {e}")