Five Python jobs that save a small business a day a week

Five automations you can build in an afternoon each: merging daily sales exports, emailing a PDF summary, filing scanned invoices, watching a folder, and pulling a portal export into a sheet. With scheduling, credentials and failure handling.

The repetitive work in a small business is usually five or six jobs, each taking twenty minutes, each done by hand every single day. Below are five of them as working Python, plus the part most tutorials skip: how to schedule a script so it actually runs at 8 am on a Tuesday when nobody is watching, where to keep the password, and what to do when it stops working in March.

Each script is short on purpose. If you can read Python at the level of our Python track, you can change these to match your file names and be running by the end of the day.

1. Merge the daily sales exports

The setup: every counter or branch drops one .xlsx into a shared folder. Somebody opens all of them and copies rows into a master sheet.

from pathlib import Path
import pandas as pd

INBOX = Path(r"C:\sales\daily")
OUT = Path(r"C:\sales\merged")

frames = []
for f in sorted(INBOX.glob("sales_*.xlsx")):
    df = pd.read_excel(f, dtype={"invoice_no": str})
    df["source_file"] = f.name
    frames.append(df)

if not frames:
    raise SystemExit("nothing matching sales_*.xlsx in the inbox")

sales = pd.concat(frames, ignore_index=True)
sales["date"] = pd.to_datetime(sales["date"], dayfirst=True, errors="coerce")
unparsed = int(sales["date"].isna().sum())
sales = sales.drop_duplicates(subset=["invoice_no"], keep="last")

OUT.mkdir(parents=True, exist_ok=True)
stamp = pd.Timestamp.today().strftime("%Y-%m-%d")
sales.to_excel(OUT / f"sales_merged_{stamp}.xlsx", index=False)
print(f"{len(sales)} rows written, {unparsed} dates could not be read")

Two lines carry the weight. dtype={"invoice_no": str} stops pandas turning 0045 into the number 45, which is the single most common way a merged sales sheet goes wrong. And dayfirst=True is not optional in India: 03/04/2026 is 3 April here and 4 March to the default parser, so without it half your year silently shifts.

errors="coerce" turns junk dates into NaT instead of crashing, and printing the count means you find out there were nine bad rows rather than losing them quietly.

2. Email a PDF summary every morning

Make the PDF first. Matplotlib writes PDF natively, so a one-page summary needs no extra library:

import matplotlib
matplotlib.use("Agg")          # required when no screen is attached
import matplotlib.pyplot as plt

daily = sales.groupby(sales["date"].dt.date)["amount"].sum()
fig, ax = plt.subplots(figsize=(8.27, 5.5))
daily.tail(14).plot(kind="bar", ax=ax)
ax.set_title(f"Last 14 days, total Rs {daily.tail(14).sum():,.0f}")
ax.set_ylabel("Rs")
fig.tight_layout()
fig.savefig("sales_summary.pdf")

Then send it:

import os, smtplib
from email.message import EmailMessage
from pathlib import Path

report = Path("sales_summary.pdf")
sender = os.environ["REPORT_FROM"]

msg = EmailMessage()
msg["From"] = sender
msg["To"] = "owner@example.com, accounts@example.com"
msg["Subject"] = f"Sales summary {report.stat().st_mtime_ns // 10**9}"
msg.set_content("Attached: last 14 days of sales from the counter exports.")
msg.add_attachment(report.read_bytes(), maintype="application",
                   subtype="pdf", filename=report.name)

with smtplib.SMTP("smtp.gmail.com", 587, timeout=30) as s:
    s.starttls()
    s.login(sender, os.environ["REPORT_APP_PASSWORD"])
    s.send_message(msg)

With a Gmail or Google Workspace account your normal password will not work here. Turn on 2-step verification, then create a 16-character app password and put that in the environment variable. Port 587 with starttls() works from almost every Indian ISP and office network; port 465 is often blocked outbound.

3. Rename and file scanned invoices by date and vendor

Supplier bills arrive as Scan_0043.pdf. Six months later nobody can find anything.

import re, shutil
from pathlib import Path
import pdfplumber

SRC, DEST, REVIEW = Path("scans/inbox"), Path("scans/filed"), Path("scans/review")
DATE = re.compile(r"(\d{2})[/-](\d{2})[/-](\d{4})")
GSTIN = re.compile(r"\b(\d{2}[A-Z]{5}\d{4}[A-Z][A-Z\d]Z[A-Z\d])\b")
VENDORS = {"27AAACX1234Z1ZP": "shree-traders", "09AABCU9603R1ZM": "metro-packaging"}

REVIEW.mkdir(parents=True, exist_ok=True)
for pdf in SRC.glob("*.pdf"):
    with pdfplumber.open(pdf) as doc:
        text = "\n".join(p.extract_text() or "" for p in doc.pages[:2])
    g, d = GSTIN.search(text), DATE.search(text)
    if not (g and d):
        shutil.move(pdf, REVIEW / pdf.name)
        continue
    vendor = VENDORS.get(g.group(1), g.group(1)[2:7].lower())
    dd, mm, yyyy = d.groups()
    folder = DEST / yyyy / mm
    folder.mkdir(parents=True, exist_ok=True)
    shutil.move(pdf, folder / f"{yyyy}-{mm}-{dd}_{vendor}_{pdf.stem}.pdf")

The review folder is the important idea. A filing script that guesses when it is unsure will scatter documents into wrong years and you will not notice for months. Anything without both a GSTIN and a date goes to a human.

extract_text() returns None for a page with no text layer, which is what you get from a flatbed scan or a phone photo. Run those through OCR first; AI OCR does it in the browser for a handful of pages, and ocrmypdf handles a batch on the command line.

4. Watch a folder and convert whatever lands in it

import time
from pathlib import Path
import pandas as pd
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler

def convert(path: Path):
    if path.suffix.lower() != ".csv":
        return
    for _ in range(20):                      # wait for the writer to finish
        size = path.stat().st_size
        time.sleep(0.5)
        if path.stat().st_size == size and size > 0:
            break
    df = pd.read_csv(path, encoding="utf-8-sig")
    df.to_excel(path.with_suffix(".xlsx"), index=False)
    print("converted", path.name)

class Handler(FileSystemEventHandler):
    def on_created(self, event):
        if not event.is_directory:
            convert(Path(event.src_path))

obs = Observer()
obs.schedule(Handler(), "exports/incoming", recursive=False)
obs.start()
try:
    while True:
        time.sleep(1)
except KeyboardInterrupt:
    obs.stop()
obs.join()

That polling loop inside convert is not padding. on_created fires when the file is created, not when it finishes being written, so a 12 MB download hands you a half-written CSV and pandas raises a parser error on a line that looks perfectly fine when you open it later. Waiting until the size stops changing fixes it.

encoding="utf-8-sig" strips the byte-order mark that Windows and most government portal exports put at the start of a CSV. Without it your first column is named invoice_no and every lookup against it fails.

5. Pull a portal export into a sheet

Most portals have a download URL behind the login. Use it rather than scraping the HTML table:

import os
from pathlib import Path
import requests

s = requests.Session()
s.headers["User-Agent"] = "internal-report-bot/1.0"
s.post("https://portal.example.com/login",
       data={"username": os.environ["PORTAL_USER"],
             "password": os.environ["PORTAL_PASS"]}, timeout=30).raise_for_status()

r = s.get("https://portal.example.com/reports/sales.csv", timeout=60)
r.raise_for_status()
if b"<html" in r.content[:300].lower():
    raise SystemExit("got a login page instead of a CSV, session probably expired")

Path("exports/incoming/portal_sales.csv").write_bytes(r.content)

That <html check catches the failure that otherwise wastes an afternoon: the request returns HTTP 200, raise_for_status() is happy, and you have written a login page to disk with a .csv extension. Two hundred bytes of the response tells you which one you got.

Where there is a captcha or an OTP, stop. Automating past a login challenge breaks most portals' terms of use, and the honest route is Playwright driving a real browser with a session you signed into yourself, or asking the provider for API access. Read the terms before you build.

Scheduling it

Runs on Use Set it with Watch out for
Office Windows PC Task Scheduler taskschd.msc, Create Task Nothing runs when the machine is off or asleep
Linux VPS cron crontab -e No environment from your shell profile
Linux, needs restart on failure systemd timer unit + .timer file More setup, proper logs via journalctl
No always-on machine GitHub Actions schedule: in the workflow Cannot touch local files or a LAN drive

A cron line, with the log redirect that makes it debuggable:

5 8 * * 1-6 /home/ops/sales/.venv/bin/python /home/ops/sales/merge.py >> /home/ops/sales/logs/merge.log 2>&1

Use the full path to the virtualenv's Python. Cron does not run your .bashrc, so a bare python is either missing or the wrong one.

In Task Scheduler the field people forget is Start in (optional) on the Actions tab. Set Program/script to C:\sales\.venv\Scripts\python.exe, Add arguments to merge.py, and Start in to C:\sales. Leave Start in blank and the working directory becomes C:\Windows\System32, every relative path breaks, and the script that ran perfectly by hand fails silently on schedule. Also tick Run whether user is logged on or not.

Keeping credentials out of the code

Put secrets in the environment, read them with os.environ, and let the script crash loudly if one is missing. A .env file next to the code, loaded with python-dotenv, is enough for a single machine:

REPORT_FROM=reports@yourfirm.in
REPORT_APP_PASSWORD=abcd efgh ijkl mnop
PORTAL_USER=ops_user

Then chmod 600 .env on Linux, and add .env to .gitignore before the first commit. An app password pushed to a public GitHub repo gets found by automated scanners in minutes, not days. On Windows, user-level environment variables set through System Properties → Environment Variables survive reboots and keep the secret out of the folder entirely.

When it breaks

It will break, and usually for one of four reasons: somebody renamed a column, the export folder moved, the portal password expired, or a Windows update rebooted the machine at 3 am. Make the script tell you:

import logging, sys, traceback

logging.basicConfig(filename="logs/merge.log", level=logging.INFO,
                    format="%(asctime)s %(levelname)s %(message)s")

def main():
    ...          # the merge code from section 1 goes here

if __name__ == "__main__":
    try:
        main()
        logging.info("ok")
    except Exception:
        logging.error("run failed\n%s", traceback.format_exc())
        sys.exit(1)

A non-zero exit code is what lets Task Scheduler and systemd show the task as failed. Write a heartbeat too: touch a last_run.txt at the end of every successful run, and a second tiny script that emails you if that file is more than 36 hours old. Silence is the dangerous failure mode, because a report that stops arriving is noticed three weeks later.

Before you leave it unattended

  1. Run it twice on the same input and confirm you do not get duplicate rows.
  2. Delete the input folder's contents and confirm it exits with a message, not a traceback.
  3. Rename a column in the source file and check the error is readable.
  4. Confirm the log file gets a line on both success and failure.
  5. Reboot the machine and confirm the schedule still fires.
  6. Write the folder paths and the email recipients in a README, because in eight months you will not remember.

Five jobs at twenty minutes a day is roughly six hours a week back. If you would rather have it built and handed over with the schedule and alerting in place, that is what our automation work covers, and the pandas parts of these scripts are explained step by step in the pandas track.

Frequently asked questions

Do I need pandas for all of this? No. Jobs 3 and 5 use only the standard library plus pdfplumber and requests. Pandas earns its place the moment you are merging spreadsheets or reshaping rows, and it is worth the install for that alone.

Can these run on a shared hosting account? Usually not, because most shared hosts block long-running processes and outbound SMTP. A small VPS, or the office PC you already own, is the practical home for scheduled scripts.

How long does one of these take to build? An afternoon each if your file formats are consistent, two or three days if the inputs are messy, different every month, or come from a portal that changes its export layout without notice.

What if the exports are PDFs instead of Excel files? Then the job is extraction first, automation second. Text-layer PDFs work with pdfplumber; scans need OCR before anything else, and accuracy there depends much more on the scan quality than on the code.

B

Written by Bablu Kumar Jha

Founder, BeeProton AI · Mumbai, Maharashtra, India

Related posts