reservation_image_parser.dart 9.68 KB
/// 从 OCR 文本中提取预订录入字段。
///
/// 针对微信聊天截图做了清洗:过滤顶部状态栏、微信消息时间戳、
/// 常见确认语等噪声,优先从“宴会日期/用餐时间/用餐时段”等标签中取值。
class ReservationImageParser {
  const ReservationImageParser._();

  static const List<String> _mealWords = <String>[
    '早餐', '午餐', '中餐', '午宴', '晚餐', '晚宴', '夜宵', '宵夜',
    '早上', '上午', '中午', '下午', '晚上',
  ];

  static ReservationImageParseResult parse(String text) {
    final String cleaned = _clean(text);
    final String normalized = cleaned
        .replaceAll(RegExp(r'\s+'), ' ')
        .replaceAll(':', ':')
        .trim();
    if (normalized.isEmpty) {
      return const ReservationImageParseResult.empty();
    }

    final String name = _extractName(cleaned);
    final String sex = _extractSex(normalized, name);

    return ReservationImageParseResult(
      name: _stripSexSuffix(name, sex),
      sex: sex,
      phone: _extractPhone(normalized),
      party: _extractParty(normalized),
      date: _extractDate(normalized),
      meal: _extractMeal(normalized),
      time: _extractTime(normalized),
      table: _extractTable(normalized),
      remark: _extractRemark(normalized),
      rawText: text,
    );
  }

  static String _clean(String text) {
    final List<String> kept = <String>[];
    for (final String rawLine in text.split(RegExp(r'[\n\r]+'))) {
      final String line = rawLine.trim();
      if (line.isEmpty) continue;
      if (_isWeChatNoise(line)) continue;
      kept.add(line);
    }
    return kept.join('\n');
  }

  static bool _isWeChatNoise(String line) {
    // 微信消息时间戳,例如:9/5下午06:29
    if (RegExp(
      r'^\d{1,2}/\d{1,2}\s*(?:上午|下午|晚上|中午)?\s*\d{1,2}[::]\d{2}$',
    ).hasMatch(line)) {
      return true;
    }
    // 顶部状态栏常见符号
    if (line.contains('®') || line.contains('"') || line.contains('“')) {
      return true;
    }
    // 常见的无信息量确认语
    const Set<String> acknowledgements = <String>{
      '好的收到', '好的', '收到', '嗯', '好', '谢谢',
    };
    if (acknowledgements.contains(line)) {
      return true;
    }
    return false;
  }

  static String _extractName(String text) {
    final RegExp labelPattern = RegExp(
      r'(?:预定贵宾|预订贵宾|预订人|预定人|订位人|联系人|客户姓名|顾客姓名|姓名|称呼)[::\s]*([^\s,,。;;]+)',
    );
    final Match? match = labelPattern.firstMatch(text);
    if (match != null && match.group(1) != null) {
      return match.group(1)!.trim();
    }

    final Match? nameBeforePhone = RegExp(
      r'([\u4e00-\u9fa5]{2,4}(?:先生|女士|小姐)?)\s*(?=1[3-9]\d{9})',
    ).firstMatch(text);
    if (nameBeforePhone != null) {
      final String candidate = nameBeforePhone.group(1)!.trim();
      const Set<String> ignored = <String>{'联系', '电话', '手机', '号码', '微信'};
      if (!ignored.contains(candidate)) {
        return candidate;
      }
    }

    final Match? leadingName = RegExp(
      r'^([\u4e00-\u9fa5]{2,4}(?:先生|女士|小姐)?)(?=\d)',
    ).firstMatch(text);
    if (leadingName != null) {
      return leadingName.group(1)!;
    }

    final RegExp nameLine = RegExp(r'^[\u4e00-\u9fa5]{2,4}(?:先生|女士|小姐)?$');
    for (final String line in text.split(RegExp(r'[\n\r]+'))) {
      if (nameLine.hasMatch(line.trim())) {
        return line.trim();
      }
    }
    return '';
  }

  static String _extractSex(String text, String name) {
    for (final String suffix in const <String>['先生', '女士', '小姐', '公司', '贵宾']) {
      if (name.endsWith(suffix)) {
        return _normalizeSex(suffix);
      }
    }

    final Match? label = RegExp(
      r'(?:称谓|性别|称呼)[::\s]*(先生|女士|公司|贵宾|男|女)',
    ).firstMatch(text);
    if (label != null) {
      return _normalizeSex(label.group(1)!);
    }

    final Match? generic = RegExp(r'(先生|女士)').firstMatch(text);
    if (generic != null) {
      return generic.group(1)!;
    }
    return '';
  }

  static String _normalizeSex(String value) {
    if (value == '男') return '先生';
    if (value == '女' || value == '小姐') return '女士';
    return value;
  }

  static String _stripSexSuffix(String name, String sex) {
    if (name.isEmpty || sex.isEmpty) return name;
    final String suffix = sex == '女士' ? '女士' : sex;
    if (name.endsWith(suffix)) {
      return name.substring(0, name.length - suffix.length);
    }
    if (sex == '女士' && name.endsWith('小姐')) {
      return name.substring(0, name.length - 2);
    }
    return name;
  }

  static String _extractPhone(String text) {
    final Match? match = RegExp(r'1[3-9]\d{9}').firstMatch(text);
    return match?.group(0) ?? '';
  }

  static String _extractParty(String text) {
    final Match? match = RegExp(r'(\d+)\s*个?\s*(?:人|位)').firstMatch(text);
    return match?.group(1) ?? '';
  }

  static String _extractDate(String text) {
    final Match? fullDate = RegExp(
      r'(\d{4})[年\-/.](\d{1,2})[月\-/.](\d{1,2})日?',
    ).firstMatch(text);
    if (fullDate != null) {
      return '${fullDate.group(1)}-${fullDate.group(2)}-${fullDate.group(3)}';
    }

    final Match? monthDay = RegExp(r'(\d{1,2})月(\d{1,2})日').firstMatch(text);
    if (monthDay != null) {
      return '${monthDay.group(1)}月${monthDay.group(2)}日';
    }

    final Match? dayOnly = RegExp(
      r'(?:宴会日期|预订日期|预定日期|用餐日期|日期)[::\s]*(\d{1,2}日(?:[((][^))]{1,8}[))])?)',
    ).firstMatch(text);
    if (dayOnly != null) {
      return dayOnly.group(1)!;
    }

    final Match? relative = RegExp(r'(今天|今日|明天|明日|后天)').firstMatch(text);
    if (relative != null) {
      return relative.group(1)!;
    }
    return '';
  }

  static String _extractMeal(String text) {
    final String mealPattern = _mealWords.join('|');
    final Match? label = RegExp(
      r'(?:用餐时段|用餐时间|餐段|时段)[::\s]*(' + mealPattern + r')',
    ).firstMatch(text);
    if (label != null) {
      return _normalizeMeal(label.group(1)!);
    }

    final Match? nearClock = RegExp(
      r'(' + mealPattern + r')\s*(\d{1,2}[::]\d{2})',
    ).firstMatch(text);
    if (nearClock != null) {
      return _normalizeMeal(nearClock.group(1)!);
    }

    final Match? generic = RegExp(r'(' + mealPattern + r')').firstMatch(text);
    return generic == null ? '' : _normalizeMeal(generic.group(1)!);
  }

  static String _normalizeMeal(String value) {
    if (value.contains('早') || value.contains('上午') || value.contains('早上')) {
      return '早餐';
    }
    if (value.contains('晚') || value.contains('夜') || value.contains('宵') || value.contains('下午')) {
      return '晚餐';
    }
    if (value.contains('午') || value.contains('中')) {
      return '午餐';
    }
    return value;
  }

  static String _extractTime(String text) {
    final Match? label = RegExp(
      r'(?:用餐时间|预订时间|预定时间|时间)[::\s]*(?:(?:' +
          _mealWords.join('|') +
          r')[::\s]*)?(\d{1,2}[::]\d{2})',
    ).firstMatch(text);
    if (label != null) {
      return label.group(1)!.replaceAll(':', ':');
    }

    final Match? clock = RegExp(r'(\d{1,2}):(\d{2})').firstMatch(text);
    if (clock != null) {
      return clock.group(0)!;
    }

    final Match? hourPoint = RegExp(r'(\d{1,2})\s*点(?:半)?').firstMatch(text);
    if (hourPoint != null) {
      final int hour = int.parse(hourPoint.group(1)!);
      final String minute = hourPoint.group(0)!.contains('半') ? '30' : '00';
      return '${hour.toString().padLeft(2, '0')}:$minute';
    }
    return '';
  }

  static String _extractTable(String text) {
    final RegExp tableLabel = RegExp(
      r'(?:桌位|桌号|桌台|桌|包间)[::\s]*([A-Za-z]?\d{1,4})',
    );
    final Match? match = tableLabel.firstMatch(text);
    if (match != null && match.group(1) != null) {
      return match.group(1)!.trim();
    }

    final Match? suffix =
        RegExp(r'([A-Za-z]{1,2}\d{1,4})\s*(?:桌|包间)').firstMatch(text);
    if (suffix != null) {
      return suffix.group(1)!;
    }

    final Match? partyTable =
        RegExp(r'(\d{1,4})\s*人?\s*(?:包间|包厢)').firstMatch(text);
    if (partyTable != null) {
      return '${partyTable.group(1)}人包间';
    }
    return '';
  }

  static String _extractRemark(String text) {
    final RegExp labelPattern = RegExp(
      r'(?:备注|要求|特殊说明)[::\s]*(.*?)(?=(?:预订人|联系人|电话|人数|时间|桌位|$))',
    );
    final Match? match = labelPattern.firstMatch(text);
    final String? value = match?.group(1);
    return value == null ? '' : value.trim();
  }
}

class ReservationImageParseResult {
  const ReservationImageParseResult({
    required this.name,
    required this.sex,
    required this.phone,
    required this.party,
    required this.date,
    required this.meal,
    required this.time,
    required this.table,
    required this.remark,
    required this.rawText,
  });

  const ReservationImageParseResult.empty()
      : name = '',
        sex = '',
        phone = '',
        party = '',
        date = '',
        meal = '',
        time = '',
        table = '',
        remark = '',
        rawText = '';

  final String name;
  final String sex;
  final String phone;
  final String party;
  final String date;
  final String meal;
  final String time;
  final String table;
  final String remark;
  final String rawText;

  bool get hasAny =>
      name.isNotEmpty ||
      sex.isNotEmpty ||
      phone.isNotEmpty ||
      time.isNotEmpty ||
      party.isNotEmpty ||
      date.isNotEmpty ||
      meal.isNotEmpty ||
      table.isNotEmpty;
}