How to pick which task to automate first
Most automation projects fail because the wrong task was chosen, not because the code was bad. Here is the arithmetic that says whether a t…
BeeProton AI& Automation Solutions
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.
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.
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.
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.
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.
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.
| 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.
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.
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.
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.
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.
Written by Bablu Kumar Jha
Founder, BeeProton AI · Mumbai, Maharashtra, India
Most automation projects fail because the wrong task was chosen, not because the code was bad. Here is the arithmetic that says whether a t…
Twelve monthly exports, slightly different headers, some duplicate invoices and no branch column. How to stack them correctly in the browse…
A sheet, a handful of validation rules, and free browser tools will carry a shop doing under 100 invoices a month. Here is the exact chain,…
We use essential cookies to run this site and, on learning and tool pages, advertising cookies from partners like Google to keep our tools free. Your tool files are never uploaded. Learn more